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 +4 -4
- data/CHANGELOG.md +38 -18
- data/CLAUDE.md +6 -3
- data/README.md +15 -5
- data/issues.rec +56 -1
- data/lib/gemvault/legacy_tarvault.rb +114 -0
- data/lib/gemvault/manifest.rb +5 -37
- data/lib/gemvault/manifest_text.rb +149 -0
- data/lib/gemvault/tarball.rb +6 -0
- data/lib/gemvault/tarvault.rb +24 -14
- data/lib/gemvault/timestamp.rb +45 -0
- data/lib/gemvault/vault.rb +30 -5
- data/lib/gemvault/vault_upgrade.rb +5 -3
- data/lib/gemvault/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8478b689eb6f6b68e0c18872f4b2ca47034b1bd93ef9d0d16ecb92e8666de85
|
|
4
|
+
data.tar.gz: cb7f53a602a6b55a1a0593eda146635197e79dadb7366ca930a1e67b5b40d80d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
13
|
-
on the read/write path (works on JRuby). The original SQLite
|
|
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
|
|
20
|
-
`--
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
runtime
|
|
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
|
|
93
|
-
|
|
94
|
-
dependencies the same way it resolves gemvault and puts their require
|
|
95
|
-
extension directories included — on `$LOAD_PATH
|
|
96
|
-
is skipped rather than fatal, which is what activation could not
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/gemvault/manifest.rb
CHANGED
|
@@ -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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/gemvault/tarball.rb
CHANGED
|
@@ -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:)
|
data/lib/gemvault/tarvault.rb
CHANGED
|
@@ -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
|
|
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 ||
|
|
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:
|
|
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 =
|
|
93
|
+
@manifest = ManifestText.parse(read_manifest)
|
|
92
94
|
Vault.assert_readable!(version: @manifest.format_version, path: @path)
|
|
93
|
-
rescue
|
|
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
|
|
99
|
-
|
|
100
|
-
raise Vault::Error, "Not a valid Tarvault (missing manifest): #{@path}" unless
|
|
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
|
-
|
|
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:
|
|
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 ==
|
|
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
|
-
|
|
136
|
-
|
|
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
|
data/lib/gemvault/vault.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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 =
|
|
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.
|
|
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,
|
|
26
|
-
#
|
|
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)
|
data/lib/gemvault/version.rb
CHANGED
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.
|
|
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
|