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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -18
- data/CLAUDE.md +6 -3
- data/README.md +20 -6
- data/issues.rec +195 -4
- data/lib/gemvault/bundler_gemfile.rb +63 -0
- data/lib/gemvault/bundler_plugin_index.rb +58 -0
- data/lib/gemvault/bundler_plugin_root.rb +59 -0
- data/lib/gemvault/cli/commands/doctor.rb +90 -2
- 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 +7 -1
|
@@ -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
|
-
|
|
43
|
-
|
|
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
|
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)
|