gemvault 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.containerignore +10 -0
  3. data/.rubocop.yml +8 -1
  4. data/CHANGELOG.md +56 -0
  5. data/CLAUDE.md +6 -5
  6. data/Dockerfile.test +14 -0
  7. data/README.md +18 -6
  8. data/Rakefile +19 -0
  9. data/docs/tarvault-findings.md +186 -0
  10. data/docs/tarvault.md +42 -0
  11. data/issues.rec +540 -17
  12. data/lib/bundler/plugin/vault_source.rb +61 -73
  13. data/lib/bundler/plugin/vaulted_gem.rb +66 -0
  14. data/lib/gemvault/archive_entry.rb +6 -0
  15. data/lib/gemvault/cli/command.rb +9 -5
  16. data/lib/gemvault/cli/commands/extract.rb +11 -11
  17. data/lib/gemvault/cli/commands/new.rb +3 -2
  18. data/lib/gemvault/cli/commands/remove.rb +15 -5
  19. data/lib/gemvault/cli/commands/upgrade.rb +72 -0
  20. data/lib/gemvault/dbvault.rb +101 -0
  21. data/lib/gemvault/deprecation.rb +48 -0
  22. data/lib/gemvault/gem_entry.rb +20 -24
  23. data/lib/gemvault/gem_extraction.rb +41 -0
  24. data/lib/gemvault/gem_reference/any_version.rb +6 -2
  25. data/lib/gemvault/gem_reference/parser.rb +44 -0
  26. data/lib/gemvault/gem_reference/specific_version.rb +9 -25
  27. data/lib/gemvault/gem_reference.rb +13 -45
  28. data/lib/gemvault/manifest.rb +78 -0
  29. data/lib/gemvault/tarball.rb +53 -0
  30. data/lib/gemvault/tarvault.rb +139 -0
  31. data/lib/gemvault/vault.rb +55 -157
  32. data/lib/gemvault/vault_path.rb +33 -0
  33. data/lib/gemvault/vault_session.rb +16 -0
  34. data/lib/gemvault/vault_upgrade.rb +94 -0
  35. data/lib/gemvault/version.rb +1 -1
  36. data/lib/gemvault.rb +2 -0
  37. data/lib/rubygems/resolver/vault_set.rb +5 -7
  38. data/lib/rubygems/source/vault.rb +28 -32
  39. data/lib/rubygems_plugin.rb +17 -6
  40. metadata +21 -33
@@ -0,0 +1,41 @@
1
+ require "rubygems/package"
2
+ require "tempfile"
3
+ require "stringio"
4
+ require_relative "vault"
5
+
6
+ module Gemvault
7
+ # Shared vault behavior: turn a gem entry (stored or on disk) into gemspecs
8
+ # and temporary files. Host classes must implement #gem_data(entry) and
9
+ # #gem_entries.
10
+ module GemExtraction
11
+ def with_gem_file(entry)
12
+ data = gem_data(entry)
13
+ Tempfile.create(["vault_gem", ".gem"]) do |tmpfile|
14
+ tmpfile.binmode
15
+ tmpfile.write(data)
16
+ tmpfile.flush
17
+ yield tmpfile.path
18
+ end
19
+ end
20
+
21
+ def spec_from_blob(entry)
22
+ bytes = gem_data(entry)
23
+ io = StringIO.new(bytes)
24
+ Gem::Package.new(io).spec
25
+ end
26
+
27
+ def specs
28
+ gem_entries.map { |entry| spec_from_blob(entry) }
29
+ end
30
+
31
+ private
32
+
33
+ def spec_from_gem_file(gem_path)
34
+ begin
35
+ Gem::Package.new(gem_path.to_s).spec
36
+ rescue StandardError => e
37
+ raise Vault::InvalidGemError, "Invalid gem file #{gem_path}: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,7 +1,11 @@
1
1
  module Gemvault
2
- class GemReference
2
+ module GemReference
3
+ ##
3
4
  # Matches every version of a named gem stored in the vault.
4
- class AnyVersion < GemReference
5
+ class AnyVersion < Data.define(:name)
6
+ include GemReference
7
+
8
+ def matches?(gem) = gem.name == name
5
9
  end
6
10
  end
7
11
  end
@@ -0,0 +1,44 @@
1
+ require "rubygems/version"
2
+
3
+ module Gemvault
4
+ module GemReference
5
+ ##
6
+ # Turns raw CLI input into the GemReference that selects it. Input is either
7
+ # a bare name ("foo") or a combined NAME-VERSION ("foo-1.2.3"); an explicit
8
+ # +version+ overrides any version embedded in the input. A name and version
9
+ # yield a SpecificVersion, a lone name an AnyVersion, and a non-exact version
10
+ # a NonExactVersionError.
11
+ class Parser < Data.define(:input, :version)
12
+ def initialize(input:, version: nil) = super
13
+
14
+ def call
15
+ boundary = version_boundary
16
+ name = boundary ? input[0...boundary] : input
17
+ embedded_version = boundary && input[(boundary + 1)..]
18
+ build(name:, version_string: version || embedded_version)
19
+ end
20
+
21
+ private
22
+
23
+ def build(name:, version_string:)
24
+ return AnyVersion.new(name:) if version_string.nil?
25
+
26
+ raise_non_exact(version_string) unless Gem::Version.correct?(version_string)
27
+
28
+ SpecificVersion.new(name:, version: Gem::Version.new(version_string))
29
+ end
30
+
31
+ def version_boundary
32
+ hyphen = input.rindex("-")
33
+ return nil unless hyphen
34
+
35
+ trailing = input[(hyphen + 1)..]
36
+ hyphen if Gem::Version.correct?(trailing)
37
+ end
38
+
39
+ def raise_non_exact(version_string)
40
+ raise NonExactVersionError, "Version must be an exact version (got: #{version_string.inspect})"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,29 +1,13 @@
1
1
  module Gemvault
2
- class GemReference
3
- # Matches one exact version of a named gem. `version:` is always a
4
- # Gem::Version (never nil, never a String) `.parse` validates and
5
- # constructs it before calling `.new`.
6
- class SpecificVersion < GemReference
7
- attr_reader :version
8
-
9
- def initialize(name:, version:)
10
- super(name: name)
11
- @version = version
12
- end
13
-
14
- def ==(other)
15
- super && version == other.version
16
- end
17
- alias eql? ==
18
-
19
- def hash
20
- [self.class, name, version].hash
21
- end
22
-
23
- def deconstruct_keys(keys)
24
- data = { name: name, version: version }
25
- keys.nil? ? data : data.slice(*keys)
26
- end
2
+ module GemReference
3
+ ##
4
+ # Matches one exact version of a named gem. +version+ is always a
5
+ # Gem::Version (never nil, never a String); GemReference.parse validates and
6
+ # constructs it before calling .new.
7
+ class SpecificVersion < Data.define(:name, :version)
8
+ include GemReference
9
+
10
+ def matches?(gem) = gem.name == name && gem.version == version.to_s
27
11
  end
28
12
  end
29
13
  end
@@ -1,54 +1,22 @@
1
1
  require "rubygems/version"
2
- require_relative "gem_reference/any_version"
3
- require_relative "gem_reference/specific_version"
4
2
 
5
3
  module Gemvault
6
- # A reference to a gem in a vault. Abstract base class for two concrete
7
- # kinds: AnyVersion (no version constraint) and SpecificVersion (an exact
8
- # Gem::Version). Not instantiable directly. `.parse` is the factory that
9
- # turns raw CLI input into one of the two subclasses.
10
- class GemReference
4
+ ##
5
+ # The role shared by every reference to a gem in a vault: it decides whether
6
+ # it +matches?+ a given GemEntry. Two value objects play the role --
7
+ # AnyVersion (no version constraint) and SpecificVersion (an exact
8
+ # Gem::Version). Players +include GemReference+ to declare the role; the
9
+ # +matches?+ contract is enforced by the "a gem reference" shared examples.
10
+ # +parse+ is the factory that turns raw CLI input into a player.
11
+ module GemReference
11
12
  class NonExactVersionError < StandardError; end
12
13
 
13
14
  def self.parse(input, version: nil)
14
- base_name, embedded = split_name_version(input)
15
- version_string = version || embedded
16
- return AnyVersion.new(name: base_name) if version_string.nil?
17
- unless Gem::Version.correct?(version_string)
18
- raise NonExactVersionError, "Version must be an exact version (got: #{version_string.inspect})"
19
- end
20
-
21
- SpecificVersion.new(name: base_name, version: Gem::Version.new(version_string))
22
- end
23
-
24
- def self.split_name_version(input)
25
- idx = input.rindex("-")
26
- return [input, nil] unless idx && Gem::Version.correct?(input[(idx + 1)..])
27
-
28
- [input[0...idx], input[(idx + 1)..]]
29
- end
30
- private_class_method :split_name_version
31
-
32
- attr_reader :name
33
-
34
- def initialize(name:)
35
- raise NotImplementedError, "abstract base use AnyVersion or SpecificVersion" if instance_of?(GemReference)
36
-
37
- @name = name
38
- end
39
-
40
- def ==(other)
41
- self.class == other.class && name == other.name
42
- end
43
- alias eql? ==
44
-
45
- def hash
46
- [self.class, name].hash
47
- end
48
-
49
- def deconstruct_keys(keys)
50
- data = { name: name }
51
- keys.nil? ? data : data.slice(*keys)
15
+ Parser.new(input:, version:).call
52
16
  end
53
17
  end
54
18
  end
19
+
20
+ require_relative "gem_reference/parser"
21
+ require_relative "gem_reference/any_version"
22
+ require_relative "gem_reference/specific_version"
@@ -0,0 +1,78 @@
1
+ require "json"
2
+ require "digest"
3
+ require_relative "gem_entry"
4
+
5
+ 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.
9
+ class Manifest < Data.define(:created_at, :records, :format_version)
10
+ FILENAME = "manifest.json".freeze
11
+ FORMAT_VERSION = 2
12
+
13
+ # One stored gem: its identity (a GemEntry) plus the integrity digest and
14
+ # encryption flag the manifest keeps alongside it.
15
+ 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
+ def filename = gem.filename
23
+
24
+ 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
+ end
33
+
34
+ def self.digest(bytes) = Digest::SHA256.hexdigest(bytes)
35
+
36
+ def self.empty(created_at:) = new(created_at:, records: [])
37
+
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
+ def initialize(created_at:, records:, format_version: FORMAT_VERSION)
46
+ super
47
+ end
48
+
49
+ def with_record(record) = with(records: records + [record])
50
+
51
+ def without(dropped) = with(records: records - dropped)
52
+
53
+ def find(entry)
54
+ records.find { |record| record.gem.same_identity_as?(entry) }
55
+ end
56
+
57
+ def matching(reference)
58
+ records.select { |record| reference.matches?(record.gem) }
59
+ end
60
+
61
+ def size = records.size
62
+
63
+ def gem_entries
64
+ records.map(&:gem).sort_by { |gem| [gem.name, gem.version] }
65
+ 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
+ end
78
+ end
@@ -0,0 +1,53 @@
1
+ require "rubygems/package"
2
+ require "tempfile"
3
+ require "pathname"
4
+ require_relative "archive_entry"
5
+
6
+ module Gemvault
7
+ # The tar file backing a Tarvault. Reads named members and rewrites the whole
8
+ # archive atomically: tar has no index and the leading manifest entry changes
9
+ # size on every mutation, so mutation means a full rewrite.
10
+ class Tarball
11
+ def initialize(path)
12
+ @path = Pathname(path)
13
+ end
14
+
15
+ def read(name)
16
+ entries.find { |entry| entry.name == name }&.bytes
17
+ end
18
+
19
+ def entries
20
+ each_entry.map { |member| ArchiveEntry.new(name: member.full_name, bytes: member.read) }
21
+ end
22
+
23
+ def write(entries)
24
+ Tempfile.create(["tarvault", ".tar"], @path.dirname) do |tmp|
25
+ write_entries(io: tmp, entries:)
26
+ tmp.flush
27
+ tmp.fsync
28
+ tmp.close
29
+ Pathname(tmp.path).rename(@path)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def each_entry(&block)
36
+ return enum_for(:each_entry) unless block
37
+
38
+ @path.open("rb") do |io|
39
+ Gem::Package::TarReader.new(io) { |reader| reader.each_entry(&block) }
40
+ end
41
+ end
42
+
43
+ def write_entries(io:, entries:)
44
+ Gem::Package::TarWriter.new(io) do |writer|
45
+ entries.each { |entry| add_entry(writer:, entry:) }
46
+ end
47
+ end
48
+
49
+ def add_entry(writer:, entry:)
50
+ writer.add_file_simple(entry.name, 0o644, entry.bytes.bytesize) { |dest| dest.write(entry.bytes) }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,139 @@
1
+ require "time"
2
+ require "pathname"
3
+ require_relative "vault"
4
+ require_relative "vault_session"
5
+ require_relative "gem_extraction"
6
+ require_relative "manifest"
7
+ require_relative "tarball"
8
+ require_relative "archive_entry"
9
+ require_relative "gem_entry"
10
+ require_relative "gem_reference"
11
+
12
+ module Gemvault
13
+ # A Tarvault: a tarball whose first entry is manifest.json and whose
14
+ # remaining entries are .gem files. Portable, dependency-free storage.
15
+ class Tarvault
16
+ extend VaultSession
17
+ include GemExtraction
18
+
19
+ attr_reader :path
20
+
21
+ def initialize(path, create: false)
22
+ @path = Pathname(path).expand_path
23
+ @archive = Tarball.new(@path)
24
+ @closed = false
25
+ create ? create_vault! : load_manifest!
26
+ end
27
+
28
+ def add(gem_path, created_at: nil)
29
+ gem_path = Pathname(gem_path).expand_path
30
+ raise Vault::NotFoundError, "Gem file not found: #{gem_path}" unless gem_path.file?
31
+
32
+ spec = spec_from_gem_file(gem_path)
33
+ entry = GemEntry.from_spec(spec, created_at: created_at || timestamp)
34
+ raise_if_duplicate(entry)
35
+ store(entry:, bytes: gem_path.binread)
36
+ end
37
+
38
+ def remove(reference)
39
+ dropped = @manifest.matching(reference)
40
+ return 0 if dropped.empty?
41
+
42
+ @manifest = @manifest.without(dropped)
43
+ remaining = survivors_excluding(dropped)
44
+ rewrite(remaining)
45
+ dropped.size
46
+ end
47
+
48
+ def gem_data(entry)
49
+ record = @manifest.find(entry)
50
+ raise Vault::NotFoundError, "Gem not found: #{entry}" unless record
51
+
52
+ bytes = @archive.read(record.filename)
53
+ raise Vault::Error, "Integrity check failed for #{record.filename}" unless record.matches?(bytes)
54
+
55
+ bytes
56
+ end
57
+
58
+ def gem_entries
59
+ @manifest.gem_entries
60
+ end
61
+
62
+ def format_version
63
+ @manifest.format_version
64
+ end
65
+
66
+ def size
67
+ @manifest.size
68
+ end
69
+
70
+ def close
71
+ @closed = true
72
+ end
73
+
74
+ def closed?
75
+ @closed
76
+ end
77
+
78
+ private
79
+
80
+ def create_vault!
81
+ raise Vault::Error, "Vault already exists: #{@path}" if @path.exist?
82
+
83
+ @manifest = Manifest.empty(created_at: timestamp)
84
+ rewrite([])
85
+ end
86
+
87
+ def load_manifest!
88
+ begin
89
+ raise Vault::NotFoundError, "Vault not found: #{@path}" unless @path.exist?
90
+
91
+ @manifest = Manifest.parse(read_manifest_json)
92
+ Vault.assert_readable!(version: @manifest.format_version, path: @path)
93
+ rescue JSON::ParserError, Gem::Package::TarInvalidError, ArgumentError, Errno::EINVAL
94
+ raise Vault::Error, "Not a valid Tarvault: #{@path}"
95
+ end
96
+ end
97
+
98
+ def read_manifest_json
99
+ json = @archive.read(Manifest::FILENAME)
100
+ raise Vault::Error, "Not a valid Tarvault (missing manifest): #{@path}" unless json
101
+
102
+ json
103
+ end
104
+
105
+ def store(entry:, bytes:)
106
+ record = build_record(entry:, bytes:)
107
+ @manifest = @manifest.with_record(record)
108
+ rewrite(survivors + [ArchiveEntry.new(name: record.filename, bytes:)])
109
+ end
110
+
111
+ def rewrite(gems)
112
+ manifest_entry = ArchiveEntry.new(name: Manifest::FILENAME, bytes: @manifest.to_json)
113
+ @archive.write([manifest_entry] + gems)
114
+ end
115
+
116
+ def survivors
117
+ @archive.entries.reject { |entry| entry.name == Manifest::FILENAME }
118
+ end
119
+
120
+ def survivors_excluding(dropped)
121
+ names = dropped.map(&:filename)
122
+ survivors.reject { |entry| names.include?(entry.name) }
123
+ end
124
+
125
+ def build_record(entry:, bytes:)
126
+ Manifest::StoredGem.new(gem: entry, sha256: Manifest.digest(bytes), encrypted: false)
127
+ end
128
+
129
+ def raise_if_duplicate(entry)
130
+ return unless @manifest.find(entry)
131
+
132
+ raise Vault::DuplicateGemError, "Gem already in vault: #{entry}"
133
+ end
134
+
135
+ def timestamp
136
+ Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
137
+ end
138
+ end
139
+ end