gemvault 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.containerignore +10 -0
  3. data/.rubocop.yml +8 -1
  4. data/CHANGELOG.md +56 -0
  5. data/CLAUDE.md +6 -5
  6. data/Dockerfile.test +14 -0
  7. data/README.md +18 -6
  8. data/Rakefile +19 -0
  9. data/docs/tarvault-findings.md +186 -0
  10. data/docs/tarvault.md +42 -0
  11. data/issues.rec +540 -17
  12. data/lib/bundler/plugin/vault_source.rb +61 -73
  13. data/lib/bundler/plugin/vaulted_gem.rb +66 -0
  14. data/lib/gemvault/archive_entry.rb +6 -0
  15. data/lib/gemvault/cli/command.rb +9 -5
  16. data/lib/gemvault/cli/commands/extract.rb +11 -11
  17. data/lib/gemvault/cli/commands/new.rb +3 -2
  18. data/lib/gemvault/cli/commands/remove.rb +15 -5
  19. data/lib/gemvault/cli/commands/upgrade.rb +72 -0
  20. data/lib/gemvault/dbvault.rb +101 -0
  21. data/lib/gemvault/deprecation.rb +48 -0
  22. data/lib/gemvault/gem_entry.rb +20 -24
  23. data/lib/gemvault/gem_extraction.rb +41 -0
  24. data/lib/gemvault/gem_reference/any_version.rb +6 -2
  25. data/lib/gemvault/gem_reference/parser.rb +44 -0
  26. data/lib/gemvault/gem_reference/specific_version.rb +9 -25
  27. data/lib/gemvault/gem_reference.rb +13 -45
  28. data/lib/gemvault/manifest.rb +78 -0
  29. data/lib/gemvault/tarball.rb +53 -0
  30. data/lib/gemvault/tarvault.rb +139 -0
  31. data/lib/gemvault/vault.rb +55 -157
  32. data/lib/gemvault/vault_path.rb +33 -0
  33. data/lib/gemvault/vault_session.rb +16 -0
  34. data/lib/gemvault/vault_upgrade.rb +94 -0
  35. data/lib/gemvault/version.rb +1 -1
  36. data/lib/gemvault.rb +2 -0
  37. data/lib/rubygems/resolver/vault_set.rb +5 -7
  38. data/lib/rubygems/source/vault.rb +28 -32
  39. data/lib/rubygems_plugin.rb +17 -6
  40. metadata +21 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75dcaa67889b01ce7e4826681a8ce1f85d9e24e5ebc86f928ab43bfacff21cf2
4
- data.tar.gz: d9ca2ade0cddd63f3ef42844324988c011e69591d0afbcd51aa1705a7d93afb4
3
+ metadata.gz: 3c643dd2d86d0a918dbecb5a8f20026746a0e60670eaa5590009fe35e6f9b9b0
4
+ data.tar.gz: fa0f695ae5d5d9ab9686caa479cc89c2c6954fc8c53bfdd02841cf12578622bd
5
5
  SHA512:
6
- metadata.gz: 6356b69dcb629795799ae84ec52a9a998c5f511a55495322fb5832fb93b1d6fb6f1bad969a9a524540806e0f9727cc6c55a793aa4eb90d06beadf0799b4f5848
7
- data.tar.gz: 3db86ee7bab4cc3cd27d390b4f7e43eb95e26d0977c2fdadcf32a18fb1084592b52e56a0ca1c775cb8f5f7aa0e8abb3d19edb8259956529d488002af1459f0ea
6
+ metadata.gz: d16d9d84c05c143a2372eaee7bd55b162aca32019a4709227f689ce47a4c725bd4d2d8a64b9b788f32d107ec37ae8c61b73178d547e60613a221bd55d48aff53
7
+ data.tar.gz: 21b8fbd66feeea5146546632f5636a194c698c2ab6aa1d51ae0d9544b021dc15f32d4c1171a98e92c67fe62b56cfe2f57dab5138fef2c8dc6a4bb5d194b1952d
data/.containerignore ADDED
@@ -0,0 +1,10 @@
1
+ .bundle
2
+ .git
3
+ gemv
4
+ pkg
5
+ tmp
6
+ vendor
7
+ *.gem
8
+ *.gemv
9
+ references
10
+ references/
data/.rubocop.yml CHANGED
@@ -17,7 +17,7 @@ plugins:
17
17
 
18
18
  AllCops:
19
19
  NewCops: enable
20
- TargetRubyVersion: 4.0
20
+ TargetRubyVersion: 3.4.8
21
21
  Exclude:
22
22
  - bin/*
23
23
  - vendor/**/*
@@ -137,6 +137,13 @@ Style/ArgumentsForwarding:
137
137
  Style/RedundantBegin:
138
138
  Enabled: false
139
139
 
140
+ # Data value objects that carry constants, nested value types, or any class
141
+ # body read best as `class X < Data.define(...)`. The assignment form the cop
142
+ # prefers leaks block-level constants to the enclosing scope and forces a
143
+ # `.freeze` (Style/MutableConstant) on the class. Permitted by David.
144
+ Style/DataInheritance:
145
+ Enabled: false
146
+
140
147
  # Compact class names are fine for small files and tests.
141
148
  Style/ClassAndModuleChildren:
142
149
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -5,6 +5,62 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+ - Tarball vault format ("Tarvault"): new vaults are portable tarballs with a
12
+ `manifest.json` index and per-gem SHA256 integrity, with no sqlite3 dependency
13
+ on the read/write path (works on JRuby). The original SQLite format
14
+ ("Dbvault") is still read transparently.
15
+ - Vaults carry an explicit on-disk **format version**, decoupled from the gem
16
+ version and validated on open; gemvault refuses a vault written by a newer
17
+ gemvault instead of misreading it.
18
+ - `gemvault upgrade` migrates a vault to the current format (e.g. SQLite → tar),
19
+ preserving every gem and timestamp, writing a `.bak` backup by default, with
20
+ `--dry-run` and `--no-backup` flags. It is a no-op on an already-current vault.
21
+
22
+ - All CLI commands accept `vault://` and `file://` locators wherever they take
23
+ a vault path, e.g. `gemvault list vault:///path/to/myvault.gemv`; resolution
24
+ is shared with the RubyGems source via `Gemvault::VaultPath` (issue #9).
25
+
26
+ ### Fixed
27
+ - `bundle plugin install bundler-source-vault` no longer dies with
28
+ `LoadError: cannot load such file -- bundler/plugin/vault_source`. The shim's
29
+ `plugins.rb` now derives the gem root from its own installed location (local
30
+ plugin root, global plugin root, or plain GEM_HOME), registers any spec dirs
31
+ RubyGems is not searching, and resets RubyGems' spec stub cache when it
32
+ predates the just-installed gems (issue #10).
33
+ - `bundle exec` no longer fails Gemfile parsing with `Could not find 'bundler'
34
+ (>= 2.0)` under `path: vendor` on rubies that ship bundler as a regular gem:
35
+ gemvault no longer declares `bundler` as a runtime dependency (issue #12).
36
+ - Vault gems whose versions carry a non-numeric suffix (e.g. `0.2.1.patch1`)
37
+ install from a vault with `gem install --pre`, matching RubyGems' prerelease
38
+ semantics (issue #6).
39
+
40
+ ### Changed
41
+ - `sqlite3` is no longer a runtime dependency. Gemvault runs dependency-free on
42
+ the tarball path (including JRuby); `sqlite3` is loaded lazily only to read a
43
+ legacy SQLite vault, with a clear error if it is not installed.
44
+ - `bundler` is no longer a runtime dependency; the Bundler plugin always runs
45
+ inside an existing Bundler process, and the declared dependency broke gem
46
+ activation in restricted GEM_PATH contexts.
47
+ - CI is unblocked (issue #11): RuboCop's TargetRubyVersion now matches the
48
+ gemspec's `required_ruby_version` floor (3.4.8), the previously-missing
49
+ `spec:integration` rake task exists alongside a non-integration `spec:core`, the
50
+ unit job no longer needs podman, and the integration job installs podman
51
+ when the runner image lacks it.
52
+ - Reading a vault's `manifest.json` is now strict: an entry missing any required
53
+ field (`name`, `version`, `platform`, `created_at`, `sha256`, `encrypted`)
54
+ raises instead of silently loading `nil` fields, so a truncated or hand-edited
55
+ manifest fails fast rather than yielding a subtly broken vault.
56
+
57
+ ### Deprecated
58
+ - The SQLite vault format is deprecated and now **read-only**: existing SQLite
59
+ vaults can be read and migrated but no longer written (`add`/`remove` raise and
60
+ point at `gemvault upgrade`). Opening one prints a one-time deprecation notice
61
+ (silenceable with `GEMVAULT_SILENCE_DEPRECATIONS=1`). SQLite support will be
62
+ removed in a future release (0.3-0.5); migrate with `gemvault upgrade`.
63
+
8
64
  ## [0.1.3] - 2026-06-22
9
65
 
10
66
  ### Added
data/CLAUDE.md CHANGED
@@ -17,6 +17,7 @@ Do NOT modify `.rubocop.yml` or use inline `# rubocop:disable` tags without expl
17
17
  9. ALWAYS write specs first. The workflow is: identify the domain concept (rule 5), write specs describing its behavior, then implement. No implementation without a failing spec.
18
18
  10. Integration specs are the first line of defense for CLI-tool bugs. For any bug reported from using the CLI tool (not the gemvault lib / Ruby API), the FIRST spec you write is an integration spec that reproduces the user's exact invocation — real subprocess, real vault, real exit code. Stub-heavy unit specs are complementary, not sufficient: they prove internal logic produces the expected value assuming surrounding wiring works, but a user's bug report is evidence the wiring didn't work.
19
19
  11. If an integration spec is not catching a reported CLI-tool bug, one of two things is true, and the fix starts by diagnosing which: (a) existing integration specs are not specific enough — extend them to cover the exact scenario before touching production code; or (b) the scenario is not spec'd at all, which means the work is not a bug fix but a new feature — write integration specs for the contract first (per rule 1), then implement.
20
+ 12. NEVER write to /tmp. Use /workspace/tmp
20
21
 
21
22
  ## Additional rules
22
23
 
@@ -25,7 +26,7 @@ Do NOT modify `.rubocop.yml` or use inline `# rubocop:disable` tags without expl
25
26
 
26
27
  ## Project Overview
27
28
 
28
- Multi-gem portable archives backed by SQLite. A single `.gemv` file contains multiple `.gem` files.
29
+ Multi-gem portable archives. A single `.gemv` file is a tarball holding multiple `.gem` files plus a `manifest.json` index; legacy SQLite vaults are read-only.
29
30
 
30
31
  Two gems, one repo:
31
32
 
@@ -56,7 +57,7 @@ gem install --source file:///path/to/myvault.gemv foo
56
57
  ## Architecture
57
58
 
58
59
  - `gemvault.gemspec` — main gem spec (name: `gemvault`)
59
- - `lib/gemvault/vault.rb` — Core vault class (SQLite CRUD for gem blobs + specs)
60
+ - `lib/gemvault/vault.rb` — Vault facade choosing a backend by file format (Tarvault current, legacy Dbvault read-only)
60
61
  - `lib/gemvault/cli.rb` — CLI dispatcher (new/add/list/remove/extract)
61
62
  - `lib/bundler/plugin/vault_source.rb` — Bundler `Plugin::API::Source` implementation
62
63
  - `lib/rubygems_plugin.rb` — RubyGems plugin: monkey-patches for `--source myvault.gemv` support
@@ -69,7 +70,7 @@ gem install --source file:///path/to/myvault.gemv foo
69
70
 
70
71
  ## Key Design Decisions
71
72
 
72
- - SQLite storage — random access, ACID, single file, inspectable with `sqlite3` CLI
73
+ - Tar storage — portable, dependency-free, single file, inspectable with `tar`; legacy SQLite vaults readable via lazily-loaded sqlite3
73
74
  - Specs extracted from gem blobs at runtime (no separate spec storage)
74
75
  - Vault opened/closed per operation in the source plugin (no persistent connection)
75
76
  - `fetch_gemspec_files` checks installed state — Bundler computes `full_gem_path` as `dirname(loaded_from)`, so `loaded_from` must point inside the gem directory
@@ -93,7 +94,7 @@ Integration tests use a manually-written Bundler plugin index to avoid rubygems.
93
94
 
94
95
  ## Dependencies
95
96
 
96
- - `sqlite3` (~> 2.0) runtime
97
- - `bundler` (>= 2.0) — runtime
97
+ - `bundler` NOT a dependency; the plugin always runs inside an existing Bundler process, and declaring it breaks gem activation under `bundle exec`'s restricted GEM_PATH
98
98
  - `command_kit` (~> 0.6) — runtime (CLI)
99
+ - `sqlite3` (~> 2.0) — NOT a runtime dependency; loaded lazily only to read a legacy SQLite (Dbvault) vault. Declared in the Gemfile for development/test.
99
100
  - `minitest`, `rake` — development
data/Dockerfile.test CHANGED
@@ -9,3 +9,17 @@ RUN cd /tmp/build \
9
9
  && gem build bundler-source-vault.gemspec \
10
10
  && gem install --no-document bundler-source-vault-*.gem \
11
11
  && rm -rf /tmp/build
12
+
13
+ # sqlite3 is not a gemvault runtime dependency; install it here so the upgrade
14
+ # integration test can read the committed legacy SQLite (Dbvault) fixture it
15
+ # migrates.
16
+ RUN gem install --no-document sqlite3
17
+
18
+ # Gem files for integration specs that install the plugin from a local gem
19
+ # index at spec runtime, kept for offline use: command_kit is gemvault's
20
+ # runtime dependency; the bundler gem lets specs simulate distro rubies where
21
+ # bundler is a regular (non-default) gem.
22
+ RUN mkdir -p /opt/gems \
23
+ && cd /opt/gems \
24
+ && gem fetch command_kit -v 0.6.0 \
25
+ && gem fetch bundler -v "$(ruby -rbundler -e 'print Bundler::VERSION')"
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Gemvault
2
2
 
3
- A gem server in a file. No HTTP. No infrastructure.
3
+ A file that behaves like a private gem server.
4
4
 
5
- A `.gemv` file is a SQLite database that contains Ruby gems. Commit it to your repo, drop it on S3, email it, put it on a USB drive Bundler and RubyGems read from it directly. Private gems without running a server.
5
+ You can commit it to your repo, drop it on S3, email it, put it on a USB drive, or whatever you feel like doing. Bundler and RubyGems read from it directly.
6
6
 
7
7
  ## Installation
8
8
 
@@ -27,7 +27,6 @@ end
27
27
  bundle install
28
28
  ```
29
29
 
30
- Bundler auto-discovers the `bundler-source-vault` plugin, installs it, and resolves gems from the vault alongside rubygems.org. No extra configuration.
31
30
 
32
31
  ### RubyGems CLI
33
32
 
@@ -44,16 +43,29 @@ gemvault add myvault.gemv foo.gem bar.gem # add .gem files
44
43
  gemvault list myvault.gemv # list contents
45
44
  gemvault remove myvault.gemv foo 1.0.0 # remove a gem
46
45
  gemvault extract myvault.gemv foo -o vendor/ # extract .gem file to disk
46
+ gemvault upgrade myvault.gemv # migrate to the current format
47
47
  ```
48
48
 
49
- ## How It Works
49
+ ## Vault format versioning
50
+
51
+ Every vault records an on-disk **format version** — `1` for the original SQLite format, `2` for the current tarball format. This version lives inside the file and is **independent of the gemvault gem version**: it changes only when the storage layout changes, so upgrading the gem never invalidates your vaults.
50
52
 
51
- A `.gemv` file is a SQLite database containing gem metadata and raw `.gem` blobs. You can inspect it directly:
53
+ gemvault reads any format up to the one it understands and **refuses a vault written by a newer gemvault** with a clear message (rather than silently misreading it).
54
+
55
+ The SQLite format (`1`) is **deprecated and read-only**: you can still read and migrate an existing SQLite vault, but `add`/`remove` are refused and print `gemvault upgrade`. Opening one shows a one-time deprecation notice (silence it with `GEMVAULT_SILENCE_DEPRECATIONS=1`). SQLite support will be removed in a future release (0.3–0.5). To migrate a vault to the current format:
52
56
 
53
57
  ```bash
54
- sqlite3 myvault.gemv "SELECT name, version, platform FROM gems"
58
+ gemvault upgrade myvault.gemv # e.g. SQLite (v1) -> tarball (v2)
59
+ gemvault upgrade myvault.gemv --dry-run # show the plan, change nothing
60
+ gemvault upgrade myvault.gemv --no-backup # skip the default myvault.gemv.bak copy
55
61
  ```
56
62
 
63
+ `upgrade` preserves every gem and its timestamp, writes `myvault.gemv.bak` by default, and is a no-op on an already-current vault.
64
+
65
+ ## How It Works
66
+
67
+ The gemv file is a tarball containing your .gem files and a json manifest. It has no dependencies other than the tar utilities that rubygems provides.
68
+
57
69
  When Bundler sees `type: :vault` in your Gemfile, it auto-installs the `bundler-source-vault` plugin from rubygems.org. The plugin implements the `Bundler::Plugin::API::Source` interface — it reads gemspecs from the vault, participates in dependency resolution, then extracts and installs gems from the vault's blob storage.
58
70
 
59
71
  The RubyGems plugin works similarly: `gem install --source vault.gemv` loads specs and extracts gems on demand.
data/Rakefile CHANGED
@@ -10,6 +10,14 @@ require "rspec/core/rake_task"
10
10
 
11
11
  RSpec::Core::RakeTask.new(:spec)
12
12
 
13
+ RSpec::Core::RakeTask.new("spec:core") do |t|
14
+ t.rspec_opts = "--tag ~integration"
15
+ end
16
+
17
+ RSpec::Core::RakeTask.new("spec:integration") do |t|
18
+ t.rspec_opts = "--tag integration"
19
+ end
20
+
13
21
  require "rubocop/rake_task"
14
22
  RuboCop::RakeTask.new
15
23
 
@@ -43,12 +51,23 @@ namespace :spec do
43
51
  task(:teardown) { destroy_cached_image if cached_image_exists? }
44
52
  end
45
53
 
54
+ directory "pkg" do
55
+ mkdir "pkg"
56
+ end
57
+
46
58
  namespace :shim do
47
59
  Bundler::GemHelper.install_tasks dir: "shim", name: "bundler-source-vault"
48
60
  CLOBBER.include "shim/pkg"
61
+
62
+ Rake::Task[:build].enhance ["pkg"] do
63
+ FileList["shim/pkg/*.gem"].each do |g|
64
+ mv g, "pkg", verbose: false
65
+ end
66
+ end
49
67
  end
50
68
 
51
69
  Rake::Task[:spec].enhance ["spec:setup"]
70
+ Rake::Task["spec:integration"].enhance ["spec:setup"]
52
71
  Rake::Task[:build].enhance ["shim:build"]
53
72
  Rake::Task[:release].enhance ["shim:release"]
54
73
  Rake::Task[:clobber].enhance ["spec:teardown"]
@@ -0,0 +1,186 @@
1
+ # Tarvault Spike — Findings
2
+
3
+ Spike for `docs/tarvault.md`: replace/augment the SQLite storage of a `.gemv`
4
+ with a **tarball** ("Tarvault") whose first entry is `manifest.json` and whose
5
+ remaining entries are `.gem` files. Motivation: drop the hard `sqlite3`
6
+ dependency (unportable, no JRuby) using only tooling that already ships with
7
+ rubygems.
8
+
9
+ ## Result
10
+
11
+ Tarvault is implemented and wired **behind the existing `Gemvault::Vault`
12
+ facade**, so the CLI, the Bundler `type: :vault` source, and the RubyGems
13
+ `--source` path use it transparently:
14
+
15
+ - `Gemvault::Vault` is now a `Forwardable` delegator that selects a backend by
16
+ file format — existing SQLite files open as `Gemvault::Dbvault` (the old code,
17
+ extracted verbatim); new or tar files as `Gemvault::Tarvault`. New vaults are
18
+ Tarvaults.
19
+ - Backends are **lazily required**, so a process that only touches Tarvaults
20
+ never loads `sqlite3` — the JRuby-portability win.
21
+ - `Gemvault::Manifest` owns `manifest.json`; `Gemvault::TarArchive` owns the
22
+ low-level tar read + atomic rewrite; `Gemvault::GemExtraction` /
23
+ `Gemvault::VaultSession` are shared mixins.
24
+
25
+ **Drop-in proof:** the entire pre-existing suite passes with *no change to any
26
+ `spec/` behavior or integration file* — only new unit specs were added, plus two
27
+ Minitest assertions that had probed a SQLite closed-handle artifact.
28
+
29
+ - Minitest: `111 tests, 0 failures` (the Vault-contract, Bundler-source, and
30
+ RubyGems-plugin tests now run on the **tar** backend via the facade).
31
+ - RSpec incl. podman integration: `88 examples, 0 failures` — real
32
+ `bundle install` (`type: :vault`), `gem install --source myvault.gemv`,
33
+ bundler-inline, and CLI, all against tar-backed vaults. *(Note: the container
34
+ image must be rebuilt (`rake spec:build`) after code changes — a stale image
35
+ silently tests old code and still passes on SQLite.)*
36
+ - New unit specs (`spec/gemvault/**`): `42 examples, 0 failures`.
37
+
38
+ A Tarvault on disk:
39
+
40
+ ```
41
+ $ file myvault.gemv # POSIX tar archive
42
+ $ tar -tf myvault.gemv
43
+ manifest.json
44
+ addressable-2.9.0.gem
45
+ rstore-0.3.9.gem
46
+ ```
47
+
48
+ ```json
49
+ // manifest.json
50
+ {
51
+ "vault_version": "2",
52
+ "format": "tarvault",
53
+ "created_at": "2026-07-11 20:32:25",
54
+ "gems": [
55
+ { "name": "addressable", "version": "2.9.0", "platform": "ruby",
56
+ "created_at": "…", "sha256": "7fdf6ac3…", "encrypted": false }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ ## Doc Q1 — Integrity: are checksums used?
62
+
63
+ Yes. Three layers exist; the manifest adds the one that matters for the container:
64
+
65
+ 1. **Tar header checksum** — every 512-byte tar header carries an octal
66
+ *byte-sum*. It protects the header only and is not cryptographic.
67
+ 2. **Inside each `.gem`** — a `.gem` already contains `checksums.yaml.gz`
68
+ (SHA256/SHA512 of its own members). This protects a gem's internals, not its
69
+ identity within the vault.
70
+ 3. **Manifest per-gem digest (added)** — each `manifest.json` record stores a
71
+ cryptographic **SHA256 of the whole `.gem` blob**
72
+ (`OpenSSL::Digest.new("SHA256")`, matching rubygems' own `Gem::Security`
73
+ convention). `Tarvault#gem_data` recomputes and compares on read, raising
74
+ `Vault::Error` on mismatch (verified by a test that flips a byte inside the
75
+ stored blob).
76
+
77
+ **Boundary:** `gem_entries`/`specs` read the manifest and do **not** re-hash
78
+ blobs; only `gem_data` (the byte-serving path used by install/extract) verifies.
79
+ This is a deliberate scope for the spike — list operations stay O(manifest). If
80
+ list-time verification is ever wanted, the same `Manifest.digest` is the hook.
81
+
82
+ ## Doc feasibility — the one-liner
83
+
84
+ `docs/tarvault.md`'s one-liner works, but for a subtle reason worth recording:
85
+
86
+ ```ruby
87
+ Enumerator.new { |y|
88
+ Gem::Package::TarReader.new(File.open(path)) { |r| r.each_entry { |e| y << Gem::Package.new(e) } }
89
+ }.map(&:spec)
90
+ ```
91
+
92
+ `TarReader#each` **closes each entry after yielding** (verified: reading a
93
+ stashed entry raises `IOError: closed Entry`). The one-liner still returns
94
+ correct specs *only* because `Enumerator.new{}.map(&:spec)` interleaves
95
+ consumption — `.spec` runs while the entry is still open. Materialize the
96
+ packages first (`.to_a` then `map(&:spec)`, or stash the entries) and every
97
+ `.spec` raises `IOError`.
98
+
99
+ **Rule:** read specs eagerly, inside the iteration. Tarvault sidesteps the trap
100
+ entirely — it lists from the manifest and reads any single gem via
101
+ `TarReader#seek` into a private `StringIO`
102
+ (`Gem::Package.new(StringIO.new(entry.read)).spec`), never depending on
103
+ post-close entry behavior.
104
+
105
+ ## Doc Q2 — Encryption: is it possible?
106
+
107
+ Yes (feasibility only — not implemented). `OpenSSL::Cipher` AES-256-GCM
108
+ round-trips raw `.gem` bytes cleanly (verified, incl. auth-tag failure on
109
+ tamper). The design already reserves the extension point: `Manifest::Record`
110
+ carries an `encrypted` flag (currently always `false`). Encryption would:
111
+
112
+ - encrypt the raw `.gem` bytes, store the ciphertext as the tar entry, and set
113
+ `encrypted: true` plus cipher/iv/auth-tag on the record;
114
+ - `gem_data` decrypts (key from env/keyring) before returning, composing with
115
+ the checksum by hashing the plaintext.
116
+
117
+ Because gemvault mediates every read and the manifest signals encryption, this
118
+ is a localized change in `gem_data`. `OpenSSL` ships on JRuby, so it does not
119
+ reintroduce a portability problem.
120
+
121
+ ## Portability (the motivation)
122
+
123
+ The tar path uses only pure-Ruby rubygems tooling (`Gem::Package::TarReader`/
124
+ `TarWriter`), `OpenSSL`, and `JSON` — all present on JRuby. The facade lazily
125
+ `require`s `dbvault` (hence `sqlite3`) **only** when opening an existing SQLite
126
+ file, so a JRuby process that only touches Tarvaults never loads `sqlite3`.
127
+
128
+ **Done:** `sqlite3` is no longer a runtime dependency. It is removed from the
129
+ gemspec and loaded lazily only to read a legacy SQLite vault; if it is absent,
130
+ `Vault.build_dbvault` raises a clear "install sqlite3 or upgrade the vault"
131
+ error. A process that only touches Tarvaults never loads it (verified by a
132
+ subprocess spec asserting `defined?(SQLite3)` is `nil`).
133
+
134
+ ## Atomicity & locking
135
+
136
+ Tar has no index, and the leading `manifest.json` changes size on every
137
+ mutation, so **every add/remove is a full rewrite** — to a sibling tempfile in
138
+ the target directory (same filesystem → atomic `File.rename`), with `fsync`
139
+ before rename. `flock` (the doc's suggestion) would guard concurrent *gemvault*
140
+ processes only; external tar edits remain unsupported by design and are out of
141
+ spike scope.
142
+
143
+ ## Follow-on / productionization notes
144
+
145
+ - Streaming rewrite: the current model reads all gem blobs into memory during a
146
+ rewrite — fine for a spike; large vaults would want a copy-through stream.
147
+ - Encryption (`encrypted` flag → real cipher params).
148
+ - List-time integrity verification, if wanted.
149
+ - Unify the two backends' error taxonomy and temp-file logic once Tarvault is
150
+ confirmed as "the way."
151
+
152
+ There is deliberately **no** format-selection flag on `gemvault new`. Tarvault is
153
+ the way forward; new vaults are always Tarvaults. A user who needs a SQLite vault
154
+ uses an older gemvault.
155
+
156
+ ## Update — format versioning & `gemvault upgrade` (implemented)
157
+
158
+ A follow-up shipped explicit format versioning and a migration command:
159
+
160
+ - Each vault records an on-disk **format version** (`1` = Dbvault, `2` = Tarvault)
161
+ read and validated on open. It is **decoupled from the gemvault gem version** —
162
+ it bumps only when the byte layout changes. `Vault::UnsupportedVersionError` is
163
+ raised for a vault newer than `Vault::CURRENT_FORMAT` instead of misreading it,
164
+ closing the "reads fail open" hole.
165
+ - `Vault.backend_for` now positively identifies the container (`:sqlite`/`:tar`/
166
+ `:unknown`) ahead of parsing and refuses unrecognized envelopes.
167
+ - `Gemvault::VaultUpgrade` (+ `gemvault upgrade`) reads a vault through its
168
+ existing backend and rewrites it in the current format with an atomic swap,
169
+ a default `.bak` backup, `--dry-run`, idempotency, and `created_at` preservation
170
+ (the write path gained `add(created_at:)`).
171
+
172
+ `sqlite3` is now optional (removed from the gemspec, loaded lazily, clear error
173
+ when absent). There is no `gemvault new --format` flag by design — Tarvault is
174
+ the only write format.
175
+
176
+ The Dbvault deprecation lifecycle is decided and phase 1 is shipped:
177
+
178
+ - **Now:** SQLite vaults are **read-only** (`Dbvault#add`/`#remove` raise
179
+ `Vault::ReadOnlyError` → `gemvault upgrade`), reads still work, and opening one
180
+ emits a once-per-process notice via `Gemvault::Deprecation` (stderr, silenceable
181
+ with `GEMVAULT_SILENCE_DEPRECATIONS=1`, suppressed during `gemvault upgrade`).
182
+ "Writable" tracks *created-this-session* so migration/legacy tooling that
183
+ constructs a Dbvault directly still works.
184
+ - **0.3–0.5:** Dbvault removed entirely.
185
+
186
+ Still open: streaming rewrite and encryption.
data/docs/tarvault.md ADDED
@@ -0,0 +1,42 @@
1
+ # GEMVAULT TAR SPIKE
2
+
3
+ ## Problem
4
+
5
+ Gemvault has been working thus far, but the sqlite3 dependency makes the gem less portable then it should be:
6
+
7
+ 1. The whole idea and basis of gemvault is that it _is_ a portable gem server in a file, and you shouldn't need dependencies to use it.
8
+ 2. sqlite3 is not supported by jruby
9
+
10
+ ## Solution
11
+
12
+ Since gemvault is just reading blobs from sqlite3, and doesn't really need any of its other stuff, I propose using a simpler storage model - a tarball. *.gem files, otherwise known as Packages, already are stored in this format, and all of the necessary equipment for reading them is already in rubygems, so it's a natural fit. If we are really worried about integrity that sqlite3 provides, we can use flock or similar to do locking. It is not intended or recommended to manipulate the tar version of the gemv file without going through gemvault, so anything beyond that is not supported. We shall refer to the tar version of a gemv file as a Tarvault and the sqlite3 version as a Dbvault to ensure communication is clear.
13
+
14
+ One can read all the gemspecs from a Tarvault using this chained oneliner:
15
+
16
+ ```ruby
17
+ gemspecs = Enumerator.new { |y| Gem::Package::TarReader.new(File.open("tarvault.gemv")) { |reader| reader.each_entry { y << Gem::Package.new(it) } } }.map(&:spec)
18
+
19
+
20
+ ```
21
+
22
+ All of the dependencies are already provided.
23
+
24
+ ## Things to think about
25
+
26
+ 1. A Dbvault makes it trivial to store additional information about a given Package. I think we can meet any needs for metadata via a json manifest or metadata file to keep as the very first entry in a Tarvault. So structure would resemble something like:
27
+
28
+ ```
29
+ $ tar -tf tarvault.gemv
30
+
31
+ manifest.json
32
+ myprivategem-0.1.0.gem
33
+ rails-8.0.0.gem
34
+
35
+ ```
36
+
37
+ 1. For files like this, e.g. files within a file, what is the usual way to maintain integrity? Are checksums used?
38
+ 2. Encryption can be supported by encrypting a Package directly, and appending the raw bytes directly to the Tarvault. The manifest can indicate that they are encrypted. OpenSSL can be used all the way through (Encryption is not to be done on this spike, but whether it is possible is a key factor)
39
+
40
+ ## Notes
41
+
42
+ 1. gemvault is below 1.0.0 at the moment and therefore does not guarantee any backwards compatibility with sqlite3 version or anything for that matter. Keeping this compatitble with the Dbvault is not something you should keep in mind. Move forward. Users can use an old version of gemvault if we decide that Tarvault is _the way_