gemvault 0.2.5 → 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.
@@ -1,4 +1,7 @@
1
1
  require_relative "../command"
2
+ require_relative "../../bundler_gemfile"
3
+ require_relative "../../bundler_plugin_index"
4
+ require_relative "../../bundler_plugin_root"
2
5
  require_relative "../../ghost_specification"
3
6
 
4
7
  module Gemvault
@@ -25,9 +28,22 @@ module Gemvault
25
28
  # Re-running bundle install afterwards triggers Bundler to reinstall
26
29
  # the plugin against whatever the current Gemfile declares. Run this
27
30
  # from the project directory.
31
+ #
32
+ # A project whose Gemfile is inline has no Gemfile to reinstall from, and
33
+ # keeps its plugins in a root Bundler will not consult unless told to.
34
+ # Both are handled below.
35
+ #
36
+ # The repair is transactional. `bundle install` does more than reinstall
37
+ # the plugin, and that extra work can fail on its own; the index the
38
+ # uninstall clears is snapshotted first and put back when the reinstall
39
+ # never happened, so a failed run leaves the machine as found rather
40
+ # than with no plugin at all (issue #27). Every failure is one line on
41
+ # stderr and exit 1 (issue #24).
28
42
  class Doctor < Command
29
43
  description "Repair broken bundler-source-vault plugin state and reinstall the plugin"
30
44
 
45
+ PLUGIN = "bundler-source-vault".freeze
46
+
31
47
  OWNED_GEMS = %w[gemvault bundler-source-vault].freeze
32
48
 
33
49
  PERMISSION_HINT = "(re-run with permissions for that gem home, e.g. sudo gemvault doctor)".freeze
@@ -39,8 +55,9 @@ module Gemvault
39
55
  print_error("#{e.message} #{PERMISSION_HINT}")
40
56
  exit(1)
41
57
  end
42
- system("bundle", "plugin", "uninstall", "bundler-source-vault", exception: true)
43
- exec("bundle", "install")
58
+ saved = index.snapshot
59
+ uninstall_plugin
60
+ reinstall_or_explain(saved)
44
61
  end
45
62
 
46
63
  private
@@ -51,6 +68,77 @@ module Gemvault
51
68
  puts "Removed ghost specification #{ghost}"
52
69
  end
53
70
  end
71
+
72
+ def gemfile
73
+ @gemfile ||= BundlerGemfile.new
74
+ end
75
+
76
+ def plugin_root
77
+ @plugin_root ||= BundlerPluginRoot.new(gemfile: gemfile)
78
+ end
79
+
80
+ def index
81
+ @index ||= BundlerPluginIndex.new(plugin_root.consulted)
82
+ end
83
+
84
+ # bundler/inline's own trick: a non-empty BUNDLE_GEMFILE is the whole of
85
+ # what Bundler::Plugin.root consults to prefer a project's plugin
86
+ # directory over the global one, so setting it reaches the entry that
87
+ # needs clearing. The file it names never has to exist.
88
+ def uninstall_env
89
+ return {} unless plugin_root.unreachable?
90
+
91
+ { "BUNDLE_GEMFILE" => "Gemfile" }
92
+ end
93
+
94
+ def uninstall_plugin
95
+ return if system(uninstall_env, "bundle", "plugin", "uninstall", PLUGIN)
96
+
97
+ print_error("bundle plugin uninstall #{PLUGIN} failed")
98
+ exit(1)
99
+ end
100
+
101
+ # `bundle install` with no Gemfile prints its entire usage screen and
102
+ # exits 10, which reads as gemvault itself failing. By this point the
103
+ # repair has already happened; only the reinstall is unavailable.
104
+ def reinstall_or_explain(saved)
105
+ return reinstall(saved) if gemfile.exist?
106
+ return explain_inline_repair if plugin_root.unreachable?
107
+
108
+ puts "No Gemfile here to reinstall from -- re-run gemvault doctor from the"
109
+ puts "project directory to reinstall the plugin."
110
+ end
111
+
112
+ def reinstall(saved)
113
+ return if system("bundle", "install")
114
+ return exit_partially_repaired if index.registered?(PLUGIN)
115
+
116
+ exit_restoring(saved)
117
+ end
118
+
119
+ # The plugin came back before the install died, so the repair stands;
120
+ # only the rest of the bundle is unfinished.
121
+ def exit_partially_repaired
122
+ print_error("bundle install failed; the #{PLUGIN} plugin itself was reinstalled " \
123
+ "-- fix the install error and re-run bundle install")
124
+ exit(1)
125
+ end
126
+
127
+ def exit_restoring(saved)
128
+ index.restore(saved)
129
+ print_error("bundle install failed before reinstalling #{PLUGIN}; restored the " \
130
+ "previous plugin index -- fix the install error and re-run gemvault doctor")
131
+ exit(1)
132
+ end
133
+
134
+ # The local path is named only when it was the uninstall's target --
135
+ # otherwise bundler worked against its global root, and naming this
136
+ # project's path would misreport what happened.
137
+ def explain_inline_repair
138
+ puts "Cleared the #{PLUGIN} plugin index at #{plugin_root.local}."
139
+ puts "No Gemfile here to reinstall from -- an inline gemfile installs the plugin"
140
+ puts "again the next time the script runs."
141
+ end
54
142
  end
55
143
  end
56
144
  end
@@ -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)