gemvault 0.2.6 → 0.2.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c8bf335258cabfbfbe016fe547e1302da87c37060d1523cae394e941bd493dd
4
- data.tar.gz: f4a027c561cb8edf619f777989654d657bae70d741106ba8730b540b38de7f2f
3
+ metadata.gz: d8478b689eb6f6b68e0c18872f4b2ca47034b1bd93ef9d0d16ecb92e8666de85
4
+ data.tar.gz: cb7f53a602a6b55a1a0593eda146635197e79dadb7366ca930a1e67b5b40d80d
5
5
  SHA512:
6
- metadata.gz: 9f3691b960faaa2d6ab493d18363091691ce3ac106a23d8859a052e946fd6aaa78178282da8269823e07c21cf52cb5ba5e9e262c51767aea19d805b620ee6287
7
- data.tar.gz: 99feeb31a5af138c4da80b61b3fe3853cd545044954b895e202d57a5f7c2817b3cb59701f752140efbce75c839120f4b6bcf0a5871a806a94cc80cc6d12670ed
6
+ metadata.gz: afa47c5b978c008d5fe46fb7399a3d1625505c8ad502d7e86e54c2f59a74b20b77077db8a31e60c70e294beeb54d51e8b808e005196c0b42f5e7e5cba0237574
7
+ data.tar.gz: b9a93c14eaeb496f1a46e78494b6129faeb8501333f678a85e8d1d07afb1a14465b32f7cc941eed294eae0bb7c319dcbbd5a0264305843394a4f1a1113a62530
data/CHANGELOG.md CHANGED
@@ -9,15 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
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.
12
+ plain-text `manifest` index and per-gem SHA256 integrity, with no sqlite3
13
+ dependency on the read/write path (works on JRuby). The original SQLite
14
+ format ("Dbvault") is still read transparently.
15
15
  - Vaults carry an explicit on-disk **format version**, decoupled from the gem
16
16
  version and validated on open; gemvault refuses a vault written by a newer
17
17
  gemvault instead of misreading it.
18
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.
19
+ preserving every gem, writing a `.bak` backup by default, with `--dry-run` and
20
+ `--no-backup` flags. Timestamps survive a v1 upgrade; a v2 vault's are
21
+ restamped, its stored times living in an index this gemvault no longer reads
22
+ (see Changed). It is a no-op on an already-current vault.
21
23
 
22
24
  - All CLI commands accept `vault://` and `file://` locators wherever they take
23
25
  a vault path, e.g. `gemvault list vault:///path/to/myvault.gemv`; resolution
@@ -83,17 +85,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
83
85
  evaluation, so `Gemvault::GemEntry` can no longer be defined twice from two
84
86
  different gem roots (issue #13).
85
87
  - `gemvault new` no longer raises `cannot load such file -- json` on a stock
86
- distro ruby. json backs the tarball vault's manifest, so every current-format
87
- vault needs it; it is a default gem upstream but a separate package on
88
- distros, where `dnf install ruby` leaves it absent. It is now a declared
89
- runtime dependency.
88
+ distro ruby, where json is a default gem upstream but a separate package that
89
+ `dnf install ruby` leaves absent. json backed the tarball vault's manifest
90
+ when this was first fixed by declaring it a runtime dependency; the manifest
91
+ is now plain text and gemvault never loads json at runtime, so the failure is
92
+ retired outright rather than papered over (see Changed, issue #25).
90
93
  - Reading a vault through the Bundler source no longer raises `cannot load such
91
94
  file -- json` either. Loading gemvault off `$LOAD_PATH` skips activation, and
92
- therefore skips its dependencies, so the declared dependency alone did not
93
- reach the plugin path. The shim now resolves gemvault's declared runtime
94
- dependencies the same way it resolves gemvault and puts their require paths —
95
- extension directories included — on `$LOAD_PATH`. A dependency it cannot find
96
- is skipped rather than fatal, which is what activation could not do.
95
+ therefore skips its dependencies, so a declared dependency (json, at the time)
96
+ never reached the plugin path. The shim now resolves gemvault's declared
97
+ runtime dependencies the same way it resolves gemvault and puts their require
98
+ paths — extension directories included — on `$LOAD_PATH`; a dependency it
99
+ cannot find is skipped rather than fatal, which is what activation could not
100
+ do. The manifest being plain text, nothing on this path requires json at all
101
+ (see Changed, issue #25).
97
102
  - `bundle install` no longer fails with `Could not find 'command_kit' (~> 0.6)`
98
103
  when gemvault is installed into the plugin root without its dependencies.
99
104
  Loading the vault source no longer activates the gemvault gem, which would
@@ -101,6 +106,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
101
106
  gemvault through `require_relative` alone (issue #13).
102
107
 
103
108
  ### Changed
109
+ - The manifest is now line-oriented text rather than JSON (vault format 3).
110
+ It was never document-shaped: a header plus one whitespace-separated line
111
+ per gem — name, version, platform, stored-at, SHA256, encrypted — with no
112
+ nesting and no free text, so `tar -xOf myvault.gemv manifest` gives you
113
+ something `grep` and `awk` read directly. Because every field comes from an
114
+ alphabet without whitespace, reading needs no parser beyond `split` plus
115
+ per-field validation: no recursion to exhaust the stack, no escape grammar,
116
+ and no library to load — which is what stops gemvault from ever activating
117
+ a json gem a project had locked (issue #25). Times are stored as
118
+ `2026-08-12T18:23:32Z`; a legacy vault's `2026-08-12 18:23:32` is converted
119
+ on upgrade. A format-2 vault (`manifest.json`) stays readable: it opens
120
+ read-only through the same path a legacy SQLite vault does, and
121
+ `gemvault upgrade` migrates it. That backend never parses the old manifest —
122
+ it derives the index from the stored gems, each of which carries its own
123
+ gemspec — so migrated gems keep their identity but are restamped, the old
124
+ index's times being unrecoverable.
104
125
  - `sqlite3` is no longer a runtime dependency. Gemvault runs dependency-free on
105
126
  the tarball path (including JRuby); `sqlite3` is loaded lazily only to read a
106
127
  legacy SQLite vault, with a clear error if it is not installed.
@@ -112,10 +133,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
112
133
  `spec:integration` rake task exists alongside a non-integration `spec:core`, the
113
134
  unit job no longer needs podman, and the integration job installs podman
114
135
  when the runner image lacks it.
115
- - Reading a vault's `manifest.json` is now strict: an entry missing any required
116
- field (`name`, `version`, `platform`, `created_at`, `sha256`, `encrypted`)
117
- raises instead of silently loading `nil` fields, so a truncated or hand-edited
118
- manifest fails fast rather than yielding a subtly broken vault.
136
+ - Reading a vault's manifest is now strict: every field is validated against
137
+ its own alphabet, so a truncated or hand-edited manifest fails fast rather
138
+ than yielding a subtly broken vault.
119
139
 
120
140
  ### Deprecated
121
141
  - The SQLite vault format is deprecated and now **read-only**: existing SQLite
data/CLAUDE.md CHANGED
@@ -27,7 +27,7 @@ Do NOT modify `.rubocop.yml` or use inline `# rubocop:disable` tags without expl
27
27
 
28
28
  ## Project Overview
29
29
 
30
- 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.
30
+ Multi-gem portable archives. A single `.gemv` file is a tarball holding multiple `.gem` files plus a line-oriented `manifest` index; legacy SQLite vaults are read-only.
31
31
 
32
32
  Two gems, one repo:
33
33
 
@@ -58,7 +58,10 @@ gem install --source file:///path/to/myvault.gemv foo
58
58
  ## Architecture
59
59
 
60
60
  - `gemvault.gemspec` — main gem spec (name: `gemvault`)
61
- - `lib/gemvault/vault.rb` — Vault facade choosing a backend by file format (Tarvault current, legacy Dbvault read-only)
61
+ - `lib/gemvault/vault.rb` — Vault facade choosing a backend by file format (Tarvault current; Dbvault and LegacyTarvault read-only)
62
+ - `lib/gemvault/legacy_tarvault.rb` — read-only backend for a format-2 (manifest.json) vault; derives its index from the stored gems instead of parsing that manifest, so `gemvault upgrade` migrates one through the ordinary pipeline
63
+ - `lib/gemvault/manifest_text.rb` — the manifest's on-disk notation: a header plus one whitespace-separated line per gem, validated field by field on read
64
+ - `lib/gemvault/timestamp.rb` — the space-free notation a vault records times in, and conversion of a legacy vault's times into it
62
65
  - `lib/gemvault/cli.rb` — CLI dispatcher (new/add/list/remove/extract)
63
66
  - `lib/gemvault/ghost_specification.rb` — installation records whose gem directory is gone; swept by `gemvault doctor` (issue #23)
64
67
  - `lib/bundler/plugin/vault_source.rb` — Bundler `Plugin::API::Source` implementation
@@ -165,6 +168,6 @@ container.
165
168
 
166
169
  - `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
167
170
  - `command_kit` (~> 0.6) — runtime (CLI)
168
- - `json` — NOT a dependency, though it backs the tarball vault's manifest. A default gem upstream; distros that unbundle it (Fedora's `rubygem-json`) still install it alongside ruby itself, and it stays requireable even under `bundle exec`'s restricted view. Declaring it would compile a native extension on every `gem install gemvault` for no gain.
171
+ - `json` — NOT a dependency and never loaded at runtime. The manifest is a table, not a document, so `Gemvault::ManifestText` reads and writes it directly; nothing in `lib/` may `require "json"`. On rubies where require resolves through gem activation, a require inside a Bundler process activates the newest installed json and a project locking an older one dies in `check_for_activated_spec!` (issue #25).
169
172
  - `sqlite3` (~> 2.0) — NOT a runtime dependency; loaded lazily only to read a legacy SQLite (Dbvault) vault. Declared in the Gemfile for development/test.
170
173
  - `minitest`, `rspec`, `rake` — development
data/README.md CHANGED
@@ -48,23 +48,33 @@ gemvault upgrade myvault.gemv # migrate to the current format
48
48
 
49
49
  ## Vault format versioning
50
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.
51
+ Every vault records an on-disk **format version** — `1` for the original SQLite format, `2` for a tarball with a JSON manifest, `3` for the current tarball with a plain-text manifest. 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.
52
52
 
53
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
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:
55
+ Formats `1` (SQLite) and `2` (JSON manifest) are **deprecated and read-only**: you can still list, extract from, and migrate an existing one, 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:
56
56
 
57
57
  ```bash
58
- gemvault upgrade myvault.gemv # e.g. SQLite (v1) -> tarball (v2)
58
+ gemvault upgrade myvault.gemv # e.g. SQLite (v1) or JSON manifest (v2) -> current (v3)
59
59
  gemvault upgrade myvault.gemv --dry-run # show the plan, change nothing
60
60
  gemvault upgrade myvault.gemv --no-backup # skip the default myvault.gemv.bak copy
61
61
  ```
62
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.
63
+ `upgrade` preserves every gem, writes `myvault.gemv.bak` by default, and is a no-op on an already-current vault. Timestamps survive a v1 upgrade; a v2 vault's are restamped, because its stored times live in the JSON index this gemvault deliberately no longer reads — the gems themselves are read straight out of the tarball.
64
64
 
65
65
  ## How It Works
66
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.
67
+ The gemv file is a tarball containing your .gem files and a plain-text manifest. It has no dependencies other than the tar utilities that rubygems provides.
68
+
69
+ ```console
70
+ $ tar -xOf myvault.gemv manifest
71
+ gemvault 3
72
+ created 2026-08-12T18:23:32Z
73
+
74
+ rails 7.1.3 ruby 2026-08-12T18:23:32Z 9f2c... 0
75
+ ```
76
+
77
+ One header, then one line per gem: name, version, platform, when it was stored, its SHA256, and whether it is encrypted. Every field is drawn from an alphabet without whitespace, so reading the index needs no parser beyond `split` — and `grep`, `awk` and `cut` work on it directly.
68
78
 
69
79
  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.
70
80
 
data/issues.rec CHANGED
@@ -1163,7 +1163,62 @@ Description: Project with Gemfile + locked json version + gemvault throws "bundl
1163
1163
  + /Users/davidgillis/.rbenv/versions/4.0.1/lib/ruby/gems/4.0.0/gems/bundler-4.0.18/lib/bundler/runtime.rb:328:in 'Bundler::Runtime#check_for_activated_spec!': You have already activated json 2.21.2, but your Gemfile requires json 2.19.7. Prepending `bundle exec` to your command may solve this. (Gem::LoadError)"
1164
1164
  +
1165
1165
  + I believe this error to be coming from gemvault's dependency on JSON. It is clear that dependencies are cursed in this project, whether its the added load time for reinstalling them on every `bundle install`, or errors like this one. Rather than rely on JSON, I think it would be better to just marshal our own manifest, or use some other serialization tool if any that rubygems already provides.
1166
- Status: open
1166
+ +
1167
+ + RESOLUTION: gemvault no longer loads json, and the manifest is no longer a
1168
+ + JSON document. Whether gemvault's require was the activator on the reported
1169
+ + machine cannot be settled from here -- on the Fedora reproduction stack
1170
+ + require resolves json from the stdlib path with no activation at all, and
1171
+ + bundler loads json itself before the vault source runs -- but on rbenv-style
1172
+ + stacks require does activate, gemvault held a live require in its
1173
+ + vault-read path, and it can no longer be the component that detonates
1174
+ + check_for_activated_spec!.
1175
+ +
1176
+ + Marshal was considered as the reporter suggested and rejected: a .gemv is a
1177
+ + file that arrives from elsewhere, the manifest is the root of trust (the
1178
+ + SHA256 digests live inside it, so nothing verifies it first), and
1179
+ + Marshal.load runs attacker-chosen code during load itself -- demonstrated
1180
+ + here, a hash key's #hash fires inside load before the caller touches the
1181
+ + result. `gemvault list` on an untrusted vault would become arbitrary code
1182
+ + execution, and Marshal's versioned wire format would break the portability
1183
+ + a .gemv exists for.
1184
+ +
1185
+ + What replaced JSON is neither: the manifest was never document-shaped. It is
1186
+ + a table -- a header plus fixed-arity records of scalars, no nesting, no free
1187
+ + text -- and every field is drawn from an alphabet without whitespace once
1188
+ + times are canonicalized (Gemvault::Timestamp; rubygems itself validates gem
1189
+ + names against /\A[a-zA-Z0-9._-]+\z/). Gemvault::ManifestText writes and
1190
+ + reads it as lines. There is no recursion to exhaust the stack, no escape
1191
+ + grammar, no backtracking, and no library to load -- the same reasoning that
1192
+ + keeps BundlerPluginIndex from requiring yaml. Reading validates every field
1193
+ + and rejects everything else, because the file comes from elsewhere; writing
1194
+ + trusts the value objects this library constructed. Precedent is rubygems'
1195
+ + own compact index and Gemfile.lock, both line-oriented text.
1196
+ +
1197
+ + Format version 3; vaults are inspectable with `tar -xOf v.gemv manifest`.
1198
+ +
1199
+ + An existing format-2 vault keeps working. Gemvault::LegacyTarvault opens one
1200
+ + read-only, exactly as Dbvault does for SQLite, so `gemvault upgrade` migrates
1201
+ + it through the ordinary pipeline rather than leaving its gems stranded behind
1202
+ + an error. That backend never parses manifest.json: it derives the index from
1203
+ + the payload, since every stored .gem carries the gemspec the manifest's
1204
+ + identity fields came from -- which keeps the old notation from re-entering
1205
+ + the codebase and makes the reader indifferent to a damaged manifest. Two
1206
+ + things the old index held are lost with it: each gem's stored time (entries
1207
+ + are restamped on open, and the deprecation notice says so) and the per-gem
1208
+ + digests (the new vault records fresh digests of exactly the bytes it copied).
1209
+ + Covered end to end by spec/integration/cli/commands/legacy_tarvault_spec.rb
1210
+ + against a committed format-2 fixture: list, extract, refused add, upgrade,
1211
+ + and writing to the upgraded vault.
1212
+ +
1213
+ + Settled after all: the Fedora image reproduces the rbenv-style stack once
1214
+ + the unbundled stdlib json is cleared away -- require then resolves through
1215
+ + gem activation, exactly the reported semantics.
1216
+ + spec/integration/locked_json_spec.rb runs the reporter's flow (a project
1217
+ + locking an older json than the machine's newest, gemvault ambient, bundle
1218
+ + exec through the vault source) and fails against a gemvault that requires
1219
+ + json on either the CLI path or the plugin path, the latter dying with the
1220
+ + reported check_for_activated_spec! error verbatim.
1221
+ Status: closed
1167
1222
 
1168
1223
  Id: 27
1169
1224
  Updated: Wed, 12 Aug 2026 16:05:55 +0000
@@ -0,0 +1,114 @@
1
+ require "pathname"
2
+ # rubygems loads zlib and psych only as it inflates a gem's metadata, so the
3
+ # rescue below requires them itself to name their errors whichever one fires.
4
+ require "psych"
5
+ require "zlib"
6
+ require_relative "vault"
7
+ require_relative "vault_session"
8
+ require_relative "gem_extraction"
9
+ require_relative "gem_entry"
10
+ require_relative "manifest_text"
11
+ require_relative "tarball"
12
+ require_relative "timestamp"
13
+ require_relative "deprecation"
14
+
15
+ module Gemvault
16
+ ##
17
+ # Read-only reader for a format-2 vault: a tarball whose index is the
18
+ # manifest.json gemvault wrote through 0.2.x. It exists so +gemvault upgrade+
19
+ # migrates such a vault through the same pipeline every other format takes,
20
+ # rather than leaving the gems stranded behind an error.
21
+ #
22
+ # It does not read that manifest. The index is derived from the payload
23
+ # instead: each stored .gem carries its own gemspec, which is where the
24
+ # manifest's identity fields came from in the first place. That keeps the
25
+ # old notation from re-entering the codebase to be parsed (issue #25) and
26
+ # makes the reader indifferent to a manifest that is damaged or missing.
27
+ #
28
+ # Two things the old index held are consequently gone. Each gem's stored
29
+ # time is unrecoverable, so entries are stamped when the vault is opened;
30
+ # and the per-gem digests cannot be checked, so a migrated gem is trusted as
31
+ # the bytes found in the archive -- the new vault records fresh digests of
32
+ # exactly what it copied.
33
+ class LegacyTarvault
34
+ extend VaultSession
35
+ include GemExtraction
36
+
37
+ FORMAT_VERSION = 2
38
+
39
+ attr_reader :path
40
+
41
+ def initialize(path)
42
+ @path = Pathname(path).expand_path
43
+ @archive = Tarball.new(@path)
44
+ @closed = false
45
+ open_vault!
46
+ end
47
+
48
+ def add(*)
49
+ raise Vault::ReadOnlyError, read_only_message
50
+ end
51
+
52
+ def remove(*)
53
+ raise Vault::ReadOnlyError, read_only_message
54
+ end
55
+
56
+ def gem_data(entry)
57
+ bytes = @archive.read(entry.filename)
58
+ raise Vault::NotFoundError, "Gem not found: #{entry}" unless bytes
59
+
60
+ bytes
61
+ end
62
+
63
+ def gem_entries
64
+ @gem_entries ||= derived_entries
65
+ end
66
+
67
+ def format_version = FORMAT_VERSION
68
+
69
+ def size = gem_members.size
70
+
71
+ def close
72
+ @closed = true
73
+ end
74
+
75
+ def closed? = @closed
76
+
77
+ private
78
+
79
+ def open_vault!
80
+ raise Vault::NotFoundError, "Vault not found: #{@path}" unless @path.exist?
81
+
82
+ @opened_at = Timestamp.now
83
+ Deprecation.warn_once(deprecation_message)
84
+ end
85
+
86
+ # Deriving the index means reading the archive and every gem in it, so it
87
+ # meets the same wreckage Tarvault's open path does and owes the user the
88
+ # same answer: the vault named, not a tar library's error.
89
+ def derived_entries
90
+ begin
91
+ gem_members.map { |member| entry_for(member) }.sort_by { |gem| [gem.name, gem.version] }
92
+ rescue Gem::Exception, Psych::Exception, Zlib::Error, ArgumentError, Errno::EINVAL
93
+ raise Vault::Error, "Not a valid Tarvault: #{@path}"
94
+ end
95
+ end
96
+
97
+ def gem_members
98
+ @archive.entries.reject { |member| member.name == ManifestText::LEGACY_FILENAME }
99
+ end
100
+
101
+ def entry_for(member)
102
+ GemEntry.from_spec(Gem::Package.new(StringIO.new(member.bytes)).spec, created_at: @opened_at)
103
+ end
104
+
105
+ def read_only_message
106
+ "Vault #{@path} is format #{FORMAT_VERSION} and read-only. Migrate it with: gemvault upgrade #{@path}"
107
+ end
108
+
109
+ def deprecation_message
110
+ "format #{FORMAT_VERSION} vaults are read-only, and their stored times cannot be recovered. " \
111
+ "Migrate #{@path} with: gemvault upgrade #{@path}"
112
+ end
113
+ end
114
+ end
@@ -1,47 +1,26 @@
1
- require "json"
2
1
  require "digest"
3
2
  require_relative "gem_entry"
4
3
 
5
4
  module Gemvault
6
- # The manifest.json stored as the first entry of a Tarvault. Records each
7
- # gem's identity, timestamp, and SHA256 digest so listing and integrity
8
- # checks never require reading every gem blob.
5
+ # The index stored as the first entry of a Tarvault. Records each gem's
6
+ # identity, timestamp, and SHA256 digest so listing and integrity checks
7
+ # never require reading every gem blob. Its on-disk notation belongs to
8
+ # Gemvault::ManifestText.
9
9
  class Manifest < Data.define(:created_at, :records, :format_version)
10
- FILENAME = "manifest.json".freeze
11
- FORMAT_VERSION = 2
10
+ FORMAT_VERSION = 3
12
11
 
13
12
  # One stored gem: its identity (a GemEntry) plus the integrity digest and
14
13
  # encryption flag the manifest keeps alongside it.
15
14
  class StoredGem < Data.define(:gem, :sha256, :encrypted)
16
- def self.from_h(hash)
17
- hash => { name:, version:, platform:, created_at:, sha256:, encrypted: }
18
- entry = GemEntry.new(name:, version:, platform:, created_at:)
19
- new(gem: entry, sha256:, encrypted:)
20
- end
21
-
22
15
  def filename = gem.filename
23
16
 
24
17
  def matches?(bytes) = Manifest.digest(bytes) == sha256
25
-
26
- def to_h
27
- {
28
- name: gem.name, version: gem.version, platform: gem.platform,
29
- created_at: gem.created_at, sha256:, encrypted:
30
- }
31
- end
32
18
  end
33
19
 
34
20
  def self.digest(bytes) = Digest::SHA256.hexdigest(bytes)
35
21
 
36
22
  def self.empty(created_at:) = new(created_at:, records: [])
37
23
 
38
- def self.parse(json)
39
- data = JSON.parse(json, symbolize_names: true)
40
- records = data.fetch(:gems, []).map { |gem| StoredGem.from_h(gem) }
41
- format_version = (data[:vault_version] || FORMAT_VERSION).to_i
42
- new(created_at: data[:created_at], records:, format_version:)
43
- end
44
-
45
24
  def initialize(created_at:, records:, format_version: FORMAT_VERSION)
46
25
  super
47
26
  end
@@ -63,16 +42,5 @@ module Gemvault
63
42
  def gem_entries
64
43
  records.map(&:gem).sort_by { |gem| [gem.name, gem.version] }
65
44
  end
66
-
67
- def to_h
68
- {
69
- vault_version: FORMAT_VERSION,
70
- format: "tarvault",
71
- created_at:,
72
- gems: records.map(&:to_h),
73
- }
74
- end
75
-
76
- def to_json(*_args) = JSON.pretty_generate(to_h)
77
45
  end
78
46
  end
@@ -0,0 +1,149 @@
1
+ require_relative "gem_entry"
2
+ require_relative "manifest"
3
+ require_relative "timestamp"
4
+
5
+ module Gemvault
6
+ ##
7
+ # The manifest's on-disk notation: a header and one line per stored gem.
8
+ #
9
+ # gemvault 3
10
+ # created 2026-08-12T16:05:55Z
11
+ #
12
+ # foo 1.0.0 ruby 2026-08-12T16:05:55Z <sha256> 0
13
+ #
14
+ # A manifest is a table, not a document -- fixed-arity records of scalars,
15
+ # with no nesting and no free text -- so it is written and read as one.
16
+ # Every field comes from an alphabet that excludes whitespace: rubygems
17
+ # validates gem names against <tt>/\A[a-zA-Z0-9._-]+\z/</tt>, versions and
18
+ # platforms are drawn from the same characters, digests are hex, the flag is
19
+ # a bit, and Gemvault::Timestamp keeps times space-free. That makes a line
20
+ # unambiguous without quoting or escapes.
21
+ #
22
+ # The gain over a general notation is what a reader cannot be asked to do:
23
+ # there is no recursion to exhaust the stack, no escape grammar, no
24
+ # backtracking, and no library to load -- the last of which is what let a
25
+ # <tt>require "json"</tt> in this path activate a gem version a project had
26
+ # locked (issue #25).
27
+ #
28
+ # Reading validates every field, because a vault is a file that arrives from
29
+ # elsewhere. Writing trusts the values this library computed or normalized
30
+ # (digests, flags, times through Gemvault::Timestamp) -- but a gem's
31
+ # identity arrives in a gem file this library did not write, and
32
+ # <tt>Gem::Package#spec</tt> never validates it, so a vault asks
33
+ # unwritable_field before admitting a gem.
34
+ module ManifestText
35
+ # Raised when text is not a manifest this gemvault can read.
36
+ class MalformedError < StandardError; end
37
+
38
+ # The archive entry holding the manifest, and the one older gemvaults
39
+ # wrote, recognized only to say so.
40
+ FILENAME = "manifest".freeze
41
+ LEGACY_FILENAME = "manifest.json".freeze
42
+
43
+ MAGIC = "gemvault".freeze
44
+
45
+ HEADER_LINES = 3
46
+
47
+ # Field alphabets, composed into RECORD below.
48
+ NAME = "[a-zA-Z0-9._-]+".freeze
49
+ VERSION = "[0-9][0-9a-zA-Z.-]*".freeze
50
+ PLATFORM = "[a-zA-Z0-9._-]+".freeze
51
+ STAMP = "\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ".freeze
52
+ DIGEST = "\\h{64}".freeze
53
+ FLAG = "[01]".freeze
54
+
55
+ # gemvault <version>
56
+ MAGIC_LINE = /\A#{MAGIC} (?<version>\d+)\z/
57
+ # created <timestamp>
58
+ CREATED_LINE = /\Acreated (?<created_at>#{STAMP})\z/
59
+ # A record line's fields, in the order the line carries them.
60
+ RECORD_FIELDS = { name: NAME, version: VERSION, platform: PLATFORM,
61
+ stored_at: STAMP, sha256: DIGEST, encrypted: FLAG }.freeze
62
+ RECORD = /\A#{RECORD_FIELDS.map { |field, alphabet| "(?<#{field}>#{alphabet})" }.join(" ")}\z/
63
+
64
+ # The spec-supplied alphabets anchored singly, for asking whether one
65
+ # field fits before a record is written.
66
+ IDENTITY_ALPHABETS = { name: /\A#{NAME}\z/, version: /\A#{VERSION}\z/, platform: /\A#{PLATFORM}\z/ }.freeze
67
+
68
+ ENCRYPTED = "1".freeze
69
+
70
+ # The lines every manifest opens with: the format version the writing
71
+ # gemvault declared, the vault's creation time, and a blank separator.
72
+ class Header < Data.define(:format_version, :created_at)
73
+ def lines = ["#{MAGIC} #{format_version}", "created #{created_at}", ""]
74
+ end
75
+
76
+ module_function
77
+
78
+ # :call-seq:
79
+ # render(manifest) -> String
80
+ #
81
+ # +manifest+ as the text a vault stores.
82
+ def render(manifest)
83
+ header = Header.new(format_version: Manifest::FORMAT_VERSION, created_at: manifest.created_at)
84
+ "#{(header.lines + manifest.records.map { |record| record_line(record) }).join("\n")}\n"
85
+ end
86
+
87
+ def record_line(record)
88
+ gem = record.gem
89
+ [gem.name, gem.version, gem.platform, gem.created_at,
90
+ record.sha256, record.encrypted ? ENCRYPTED : "0"].join(" ")
91
+ end
92
+
93
+ # :call-seq:
94
+ # unwritable_field(entry) -> Symbol or nil
95
+ #
96
+ # The first of +entry+'s identity fields holding a value outside its
97
+ # alphabet, or nil when a record for +entry+ would read back intact.
98
+ def unwritable_field(entry)
99
+ IDENTITY_ALPHABETS.each_key.find { |field| !IDENTITY_ALPHABETS[field].match?(entry.public_send(field)) }
100
+ end
101
+
102
+ # :call-seq:
103
+ # parse(text) -> Manifest
104
+ #
105
+ # The Manifest +text+ describes. Raises MalformedError for anything outside
106
+ # the notation above; the version a well-formed header declares is returned
107
+ # as parsed, readability being Vault.assert_readable!'s question.
108
+ def parse(text)
109
+ lines = text.to_s.lines(chomp: true)
110
+ header = read_header(lines)
111
+ records = lines.drop(HEADER_LINES).map { |line| read_record(line) }
112
+ Manifest.new(created_at: header.created_at, records:, format_version: header.format_version)
113
+ end
114
+
115
+ def read_header(lines)
116
+ raise MalformedError, "Not a gemvault manifest: no header" if lines.size < HEADER_LINES
117
+
118
+ magic, created, separator = lines
119
+ reject(separator, reason: "expected a blank line after the header") unless separator.empty?
120
+ Header.new(format_version: read_version(magic), created_at: read_created(created))
121
+ end
122
+
123
+ def read_version(line)
124
+ Integer(capture(line, pattern: MAGIC_LINE, expecting: "expected a #{MAGIC} version line")[:version], 10)
125
+ end
126
+
127
+ def read_created(line) = capture(line, pattern: CREATED_LINE, expecting: "expected a created line")[:created_at]
128
+
129
+ def capture(line, pattern:, expecting:)
130
+ pattern.match(line) || reject(line, reason: expecting)
131
+ end
132
+
133
+ def read_record(line)
134
+ fields = capture(line, pattern: RECORD, expecting: "expected a gem record")
135
+ entry = GemEntry.new(name: fields[:name], version: fields[:version],
136
+ platform: fields[:platform], created_at: fields[:stored_at])
137
+ Manifest::StoredGem.new(gem: entry, sha256: fields[:sha256], encrypted: fields[:encrypted] == ENCRYPTED)
138
+ end
139
+
140
+ # Truncated because a rejected line is arbitrary bytes from a file the
141
+ # caller did not write, and it is about to be printed.
142
+ def reject(line, reason:)
143
+ raise MalformedError, "Not a gemvault manifest (#{reason}): #{line.to_s[0, 60].inspect}"
144
+ end
145
+
146
+ private_class_method :record_line, :read_header, :read_version, :read_created,
147
+ :capture, :read_record, :reject
148
+ end
149
+ end
@@ -20,6 +20,12 @@ module Gemvault
20
20
  each_entry.map { |member| ArchiveEntry.new(name: member.full_name, bytes: member.read) }
21
21
  end
22
22
 
23
+ # The member names alone. Reads tar headers without any member's bytes, so
24
+ # a caller asking what kind of vault this is does not load the whole file.
25
+ def names
26
+ each_entry.map(&:full_name)
27
+ end
28
+
23
29
  def write(entries)
24
30
  Tempfile.create(["tarvault", ".tar"], @path.dirname) do |tmp|
25
31
  write_entries(io: tmp, entries:)
@@ -1,16 +1,17 @@
1
- require "time"
2
1
  require "pathname"
3
2
  require_relative "vault"
4
3
  require_relative "vault_session"
5
4
  require_relative "gem_extraction"
6
5
  require_relative "manifest"
6
+ require_relative "manifest_text"
7
+ require_relative "timestamp"
7
8
  require_relative "tarball"
8
9
  require_relative "archive_entry"
9
10
  require_relative "gem_entry"
10
11
  require_relative "gem_reference"
11
12
 
12
13
  module Gemvault
13
- # A Tarvault: a tarball whose first entry is manifest.json and whose
14
+ # A Tarvault: a tarball whose first entry is the manifest and whose
14
15
  # remaining entries are .gem files. Portable, dependency-free storage.
15
16
  class Tarvault
16
17
  extend VaultSession
@@ -30,7 +31,8 @@ module Gemvault
30
31
  raise Vault::NotFoundError, "Gem file not found: #{gem_path}" unless gem_path.file?
31
32
 
32
33
  spec = spec_from_gem_file(gem_path)
33
- entry = GemEntry.from_spec(spec, created_at: created_at || timestamp)
34
+ entry = GemEntry.from_spec(spec, created_at: Timestamp.canonical(created_at || Timestamp.now))
35
+ raise_if_unwritable(entry, gem_path:)
34
36
  raise_if_duplicate(entry)
35
37
  store(entry:, bytes: gem_path.binread)
36
38
  end
@@ -80,7 +82,7 @@ module Gemvault
80
82
  def create_vault!
81
83
  raise Vault::Error, "Vault already exists: #{@path}" if @path.exist?
82
84
 
83
- @manifest = Manifest.empty(created_at: timestamp)
85
+ @manifest = Manifest.empty(created_at: Timestamp.now)
84
86
  rewrite([])
85
87
  end
86
88
 
@@ -88,18 +90,18 @@ module Gemvault
88
90
  begin
89
91
  raise Vault::NotFoundError, "Vault not found: #{@path}" unless @path.exist?
90
92
 
91
- @manifest = Manifest.parse(read_manifest_json)
93
+ @manifest = ManifestText.parse(read_manifest)
92
94
  Vault.assert_readable!(version: @manifest.format_version, path: @path)
93
- rescue JSON::ParserError, Gem::Package::TarInvalidError, ArgumentError, Errno::EINVAL
95
+ rescue ManifestText::MalformedError, Gem::Package::TarInvalidError, ArgumentError, Errno::EINVAL
94
96
  raise Vault::Error, "Not a valid Tarvault: #{@path}"
95
97
  end
96
98
  end
97
99
 
98
- def read_manifest_json
99
- json = @archive.read(Manifest::FILENAME)
100
- raise Vault::Error, "Not a valid Tarvault (missing manifest): #{@path}" unless json
100
+ def read_manifest
101
+ text = @archive.read(ManifestText::FILENAME)
102
+ raise Vault::Error, "Not a valid Tarvault (missing manifest): #{@path}" unless text
101
103
 
102
- json
104
+ text
103
105
  end
104
106
 
105
107
  def store(entry:, bytes:)
@@ -109,12 +111,12 @@ module Gemvault
109
111
  end
110
112
 
111
113
  def rewrite(gems)
112
- manifest_entry = ArchiveEntry.new(name: Manifest::FILENAME, bytes: @manifest.to_json)
114
+ manifest_entry = ArchiveEntry.new(name: ManifestText::FILENAME, bytes: ManifestText.render(@manifest))
113
115
  @archive.write([manifest_entry] + gems)
114
116
  end
115
117
 
116
118
  def survivors
117
- @archive.entries.reject { |entry| entry.name == Manifest::FILENAME }
119
+ @archive.entries.reject { |entry| entry.name == ManifestText::FILENAME }
118
120
  end
119
121
 
120
122
  def survivors_excluding(dropped)
@@ -132,8 +134,16 @@ module Gemvault
132
134
  raise Vault::DuplicateGemError, "Gem already in vault: #{entry}"
133
135
  end
134
136
 
135
- def timestamp
136
- Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
137
+ # A spec arrives inside a gem file this library did not write, and
138
+ # Gem::Package#spec never validates it; a field the manifest's notation
139
+ # cannot hold would corrupt the whole vault on the next read.
140
+ def raise_if_unwritable(entry, gem_path:)
141
+ field = ManifestText.unwritable_field(entry)
142
+ return unless field
143
+
144
+ value = entry.public_send(field).to_s[0, 60]
145
+ raise Vault::InvalidGemError,
146
+ "Invalid gem file #{gem_path}: #{field} #{value.inspect} cannot be stored in a manifest"
137
147
  end
138
148
  end
139
149
  end
@@ -0,0 +1,45 @@
1
+ module Gemvault
2
+ ##
3
+ # The notation a vault records times in.
4
+ #
5
+ # A manifest is a table of whitespace-separated fields (see
6
+ # Gemvault::ManifestText), so a stored time may not contain a space. ISO 8601
7
+ # in UTC satisfies that, sorts lexically, and is what a reader expects.
8
+ #
9
+ # Legacy SQLite vaults stored <tt>"2000-01-01 00:00:00"</tt>, and
10
+ # +gemvault upgrade+ carries those values straight into the new vault, so the
11
+ # two known notations are accepted and everything else is refused rather than
12
+ # written into a manifest it would corrupt.
13
+ module Timestamp
14
+ # Raised when a value is in no notation a vault can store.
15
+ class Error < StandardError; end
16
+
17
+ FORMAT = "%Y-%m-%dT%H:%M:%SZ".freeze
18
+
19
+ # What FORMAT produces: 2026-08-12T16:05:55Z.
20
+ CANONICAL = /\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\z/
21
+
22
+ # What Dbvault wrote: the same instant with a space for the T and no zone.
23
+ LEGACY = /\A(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d)\z/
24
+
25
+ module_function
26
+
27
+ # This instant, in the vault's notation.
28
+ def now = Time.now.utc.strftime(FORMAT)
29
+
30
+ # :call-seq:
31
+ # canonical(value) -> String
32
+ #
33
+ # +value+ in the vault's notation, converting a legacy vault's notation on
34
+ # the way. Raises Error for anything else.
35
+ def canonical(value)
36
+ text = value.to_s
37
+ return text if CANONICAL.match?(text)
38
+
39
+ legacy = LEGACY.match(text)
40
+ raise Error, "Not a time a vault can store: #{text.inspect}" unless legacy
41
+
42
+ "#{legacy[1]}T#{legacy[2]}Z"
43
+ end
44
+ end
45
+ end
@@ -4,9 +4,10 @@ require_relative "vault_session"
4
4
 
5
5
  module Gemvault
6
6
  # The public vault interface. Delegates storage to a backend chosen by file
7
- # format: a Dbvault (SQLite) for existing SQLite files, a Tarvault (tarball)
8
- # otherwise. New vaults are Tarvaults. Only the selected backend is loaded,
9
- # so the tar path never requires sqlite3.
7
+ # format: a Dbvault (SQLite) for existing SQLite files, a read-only
8
+ # LegacyTarvault for a tarball still indexed by manifest.json, and a Tarvault
9
+ # for every other tarball. New vaults are Tarvaults. Only the selected
10
+ # backend is loaded, so the tar path never requires sqlite3.
10
11
  class Vault
11
12
  extend VaultSession
12
13
  extend Forwardable
@@ -22,7 +23,7 @@ module Gemvault
22
23
  TAR_MAGIC = "ustar".freeze
23
24
  TAR_MAGIC_OFFSET = 257
24
25
 
25
- CURRENT_FORMAT = 2
26
+ CURRENT_FORMAT = 3
26
27
  MIN_READABLE_FORMAT = 1
27
28
 
28
29
  def_delegators :@backend,
@@ -85,7 +86,31 @@ module Gemvault
85
86
 
86
87
  def self.build_tarvault(path, create:)
87
88
  require_relative "tarvault"
88
- Tarvault.new(path, create:)
89
+ return Tarvault.new(path, create:) if create || !legacy_tarvault?(path)
90
+
91
+ require_relative "legacy_tarvault"
92
+ LegacyTarvault.new(path)
93
+ end
94
+
95
+ # A tarball indexed the way vaults were through format 2. Asked by name
96
+ # rather than by version, because the version is recorded in the very
97
+ # index this gemvault no longer reads.
98
+ #
99
+ # An archive too damaged to enumerate answers no rather than raising here.
100
+ # This runs ahead of every backend, so a wreck raised from it would escape
101
+ # the rescue Tarvault opens with and reach the user as a tar library's
102
+ # backtrace; declining instead leaves that path to report it as the
103
+ # Vault::Error it has always been.
104
+ def self.legacy_tarvault?(path)
105
+ require_relative "manifest_text"
106
+ require_relative "tarball"
107
+
108
+ begin
109
+ names = Tarball.new(path).names
110
+ !names.include?(ManifestText::FILENAME) && names.include?(ManifestText::LEGACY_FILENAME)
111
+ rescue Gem::Package::Error, ArgumentError, Errno::EINVAL
112
+ false
113
+ end
89
114
  end
90
115
 
91
116
  def initialize(path, create: false)
@@ -6,7 +6,9 @@ require_relative "deprecation"
6
6
  module Gemvault
7
7
  # Migrates a vault to the current storage format by reading it through its
8
8
  # existing backend and rewriting it through the current-format writer, then
9
- # atomically swapping the file into place. Preserves each gem's created_at.
9
+ # atomically swapping the file into place. Each gem's created_at carries
10
+ # over as the source backend reports it -- which for a format-2 vault is a
11
+ # fresh stamp, its stored times being unreadable (see LegacyTarvault).
10
12
  class VaultUpgrade
11
13
  include FileUtils
12
14
 
@@ -22,8 +24,8 @@ module Gemvault
22
24
  end
23
25
  end
24
26
 
25
- # Copies gems from a source vault into a target vault, preserving each
26
- # gem's stored timestamp. Holds the two endpoints so the per-gem call
27
+ # Copies gems from a source vault into a target vault, carrying each
28
+ # entry's created_at across. Holds the two endpoints so the per-gem call
27
29
  # takes only the entry.
28
30
  class GemCopy < Data.define(:source, :target)
29
31
  def call(entry)
@@ -1,3 +1,3 @@
1
1
  module Gemvault
2
- VERSION = "0.2.6".freeze
2
+ VERSION = "0.2.7".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemvault
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.6
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Gillis
@@ -78,9 +78,12 @@ files:
78
78
  - lib/gemvault/gem_reference/parser.rb
79
79
  - lib/gemvault/gem_reference/specific_version.rb
80
80
  - lib/gemvault/ghost_specification.rb
81
+ - lib/gemvault/legacy_tarvault.rb
81
82
  - lib/gemvault/manifest.rb
83
+ - lib/gemvault/manifest_text.rb
82
84
  - lib/gemvault/tarball.rb
83
85
  - lib/gemvault/tarvault.rb
86
+ - lib/gemvault/timestamp.rb
84
87
  - lib/gemvault/vault.rb
85
88
  - lib/gemvault/vault_destination.rb
86
89
  - lib/gemvault/vault_path.rb