gemvault 0.1.3 → 0.1.5
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 +26 -0
- data/CLAUDE.md +1 -1
- data/Dockerfile.test +4 -0
- data/README.md +23 -3
- data/Rakefile +37 -17
- data/docs/tarvault-findings.md +186 -0
- data/docs/tarvault.md +42 -0
- data/lib/bundler/plugin/vault_source.rb +14 -1
- data/lib/gemvault/cli/commands/upgrade.rb +48 -0
- data/lib/gemvault/dbvault.rb +98 -0
- data/lib/gemvault/deprecation.rb +46 -0
- data/lib/gemvault/gem_extraction.rb +45 -0
- data/lib/gemvault/manifest.rb +76 -0
- data/lib/gemvault/tar_archive.rb +66 -0
- data/lib/gemvault/tarvault.rb +148 -0
- data/lib/gemvault/vault.rb +53 -159
- data/lib/gemvault/vault_session.rb +16 -0
- data/lib/gemvault/vault_upgrade.rb +87 -0
- data/lib/gemvault/version.rb +1 -1
- data/lib/gemvault.rb +1 -0
- data/lib/rubygems_plugin.rb +9 -0
- metadata +17 -20
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "rubygems/package"
|
|
2
|
+
require "tempfile"
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "vault"
|
|
5
|
+
|
|
6
|
+
module Gemvault
|
|
7
|
+
# Shared vault behavior: turn gem bytes (stored or on disk) into gemspecs and
|
|
8
|
+
# temporary files. Host classes must implement #gem_data(name, version,
|
|
9
|
+
# platform:) and #gem_entries.
|
|
10
|
+
module GemExtraction
|
|
11
|
+
def with_gem_file(name, version, platform: "ruby")
|
|
12
|
+
tmpfile = write_tempfile(gem_data(name, version, platform: platform))
|
|
13
|
+
begin
|
|
14
|
+
yield tmpfile.path
|
|
15
|
+
ensure
|
|
16
|
+
tmpfile.close unless tmpfile.closed?
|
|
17
|
+
tmpfile.unlink
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def spec_from_blob(name, version, platform = "ruby")
|
|
22
|
+
Gem::Package.new(StringIO.new(gem_data(name, version, platform: platform))).spec
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def specs
|
|
26
|
+
gem_entries.map { |entry| spec_from_blob(entry.name, entry.version, entry.platform) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def spec_from_gem_file(gem_path)
|
|
32
|
+
Gem::Package.new(gem_path).spec
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
raise Vault::InvalidGemError, "Invalid gem file #{gem_path}: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write_tempfile(data)
|
|
38
|
+
tmpfile = Tempfile.new(["vault_gem", ".gem"])
|
|
39
|
+
tmpfile.binmode
|
|
40
|
+
tmpfile.write(data)
|
|
41
|
+
tmpfile.close
|
|
42
|
+
tmpfile
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
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
|
|
10
|
+
FILENAME = "manifest.json".freeze
|
|
11
|
+
FORMAT_VERSION = 2
|
|
12
|
+
DIGEST = "SHA256".freeze
|
|
13
|
+
|
|
14
|
+
# One gem's metadata inside a manifest.
|
|
15
|
+
Record = Struct.new(:name, :version, :platform, :created_at, :sha256, :encrypted, keyword_init: true) do
|
|
16
|
+
def to_gem_entry
|
|
17
|
+
GemEntry.new(name: name, version: version, platform: platform, created_at: created_at)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def filename
|
|
21
|
+
to_gem_entry.filename
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.digest(bytes)
|
|
26
|
+
OpenSSL::Digest.new(DIGEST).hexdigest(bytes)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.empty(created_at:)
|
|
30
|
+
new(created_at: created_at, records: [])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.parse(json)
|
|
34
|
+
data = JSON.parse(json)
|
|
35
|
+
records = data.fetch("gems", []).map { |g| Record.new(**g.transform_keys(&:to_sym)) }
|
|
36
|
+
new(
|
|
37
|
+
created_at: data["created_at"],
|
|
38
|
+
records: records,
|
|
39
|
+
format_version: (data["vault_version"] || FORMAT_VERSION).to_i,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :records, :created_at, :format_version
|
|
44
|
+
|
|
45
|
+
def initialize(created_at:, records:, format_version: FORMAT_VERSION)
|
|
46
|
+
@created_at = created_at
|
|
47
|
+
@records = records
|
|
48
|
+
@format_version = format_version
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def with(record)
|
|
52
|
+
self.class.new(created_at: created_at, records: records + [record], format_version: format_version)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def without(dropped)
|
|
56
|
+
self.class.new(created_at: created_at, records: records - dropped, format_version: format_version)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find(name, version, platform)
|
|
60
|
+
records.find { |r| r.name == name && r.version == version && r.platform == platform }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def gem_entries
|
|
64
|
+
records.sort_by { |r| [r.name, r.version] }.map(&:to_gem_entry)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_json(*_args)
|
|
68
|
+
JSON.pretty_generate(
|
|
69
|
+
"vault_version" => FORMAT_VERSION,
|
|
70
|
+
"format" => "tarvault",
|
|
71
|
+
"created_at" => created_at,
|
|
72
|
+
"gems" => records.map(&:to_h),
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "rubygems/package"
|
|
2
|
+
require "tempfile"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "manifest"
|
|
5
|
+
|
|
6
|
+
module Gemvault
|
|
7
|
+
# The tar file backing a Tarvault. Reads named entries and rewrites the
|
|
8
|
+
# whole archive atomically: tar has no index and the leading manifest entry
|
|
9
|
+
# changes size on every mutation, so mutation means a full rewrite.
|
|
10
|
+
class TarArchive
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def read(name)
|
|
16
|
+
bytes = nil
|
|
17
|
+
each_entry { |entry| bytes = entry.read if entry.full_name == name }
|
|
18
|
+
bytes
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def gem_pairs
|
|
22
|
+
pairs = []
|
|
23
|
+
each_entry do |entry|
|
|
24
|
+
pairs << [entry.full_name, entry.read] unless entry.full_name == Manifest::FILENAME
|
|
25
|
+
end
|
|
26
|
+
pairs
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(pairs)
|
|
30
|
+
tmp = Tempfile.create(["tarvault", ".tar"], File.dirname(@path))
|
|
31
|
+
write_entries(tmp, pairs)
|
|
32
|
+
tmp.flush
|
|
33
|
+
tmp.fsync
|
|
34
|
+
tmp.close
|
|
35
|
+
File.rename(tmp.path, @path)
|
|
36
|
+
rescue StandardError
|
|
37
|
+
cleanup(tmp)
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def each_entry(&)
|
|
44
|
+
File.open(@path, "rb") do |io|
|
|
45
|
+
Gem::Package::TarReader.new(io) { |reader| reader.each_entry(&) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def write_entries(io, pairs)
|
|
50
|
+
Gem::Package::TarWriter.new(io) do |writer|
|
|
51
|
+
pairs.each { |name, bytes| add_entry(writer, name, bytes) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_entry(writer, name, bytes)
|
|
56
|
+
writer.add_file_simple(name, 0o644, bytes.bytesize) { |dest| dest.write(bytes) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def cleanup(tmp)
|
|
60
|
+
return unless tmp
|
|
61
|
+
|
|
62
|
+
tmp.close unless tmp.closed?
|
|
63
|
+
FileUtils.rm_f(tmp.path)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "vault"
|
|
4
|
+
require_relative "vault_session"
|
|
5
|
+
require_relative "gem_extraction"
|
|
6
|
+
require_relative "manifest"
|
|
7
|
+
require_relative "tar_archive"
|
|
8
|
+
require_relative "gem_reference"
|
|
9
|
+
|
|
10
|
+
module Gemvault
|
|
11
|
+
# A Tarvault: a tarball whose first entry is manifest.json and whose
|
|
12
|
+
# remaining entries are .gem files. Portable, dependency-free storage.
|
|
13
|
+
class Tarvault
|
|
14
|
+
extend VaultSession
|
|
15
|
+
include GemExtraction
|
|
16
|
+
|
|
17
|
+
attr_reader :path
|
|
18
|
+
|
|
19
|
+
def initialize(path, create: false)
|
|
20
|
+
@path = File.expand_path(path)
|
|
21
|
+
@archive = TarArchive.new(@path)
|
|
22
|
+
@closed = false
|
|
23
|
+
create ? create_vault! : load_manifest!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add(gem_path, created_at: nil)
|
|
27
|
+
gem_path = File.expand_path(gem_path)
|
|
28
|
+
raise Vault::NotFoundError, "Gem file not found: #{gem_path}" unless File.file?(gem_path)
|
|
29
|
+
|
|
30
|
+
spec = spec_from_gem_file(gem_path)
|
|
31
|
+
raise_if_duplicate(spec)
|
|
32
|
+
store(spec, File.binread(gem_path), created_at || timestamp)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove(reference)
|
|
36
|
+
dropped = matching_records(reference)
|
|
37
|
+
return 0 if dropped.empty?
|
|
38
|
+
|
|
39
|
+
@manifest = @manifest.without(dropped)
|
|
40
|
+
rewrite(survivors_excluding(dropped))
|
|
41
|
+
dropped.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def gem_data(name, version, platform: "ruby")
|
|
45
|
+
record = @manifest.find(name, version, platform)
|
|
46
|
+
raise Vault::NotFoundError, "Gem not found: #{name}-#{version} (#{platform})" unless record
|
|
47
|
+
|
|
48
|
+
verify(@archive.read(record.filename), record)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def gem_entries
|
|
52
|
+
@manifest.gem_entries
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def format_version
|
|
56
|
+
@manifest.format_version
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def size
|
|
60
|
+
@manifest.records.size
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
@closed = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def closed?
|
|
68
|
+
@closed
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def create_vault!
|
|
74
|
+
raise Vault::Error, "Vault already exists: #{@path}" if File.exist?(@path)
|
|
75
|
+
|
|
76
|
+
@manifest = Manifest.empty(created_at: timestamp)
|
|
77
|
+
rewrite([])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load_manifest!
|
|
81
|
+
raise Vault::NotFoundError, "Vault not found: #{@path}" unless File.exist?(@path)
|
|
82
|
+
|
|
83
|
+
@manifest = Manifest.parse(read_manifest_json)
|
|
84
|
+
Vault.assert_readable!(@manifest.format_version, @path)
|
|
85
|
+
rescue JSON::ParserError, Gem::Package::TarInvalidError, ArgumentError, Errno::EINVAL
|
|
86
|
+
raise Vault::Error, "Not a valid Tarvault: #{@path}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_manifest_json
|
|
90
|
+
json = @archive.read(Manifest::FILENAME)
|
|
91
|
+
raise Vault::Error, "Not a valid Tarvault (missing manifest): #{@path}" unless json
|
|
92
|
+
|
|
93
|
+
json
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def store(spec, bytes, created_at)
|
|
97
|
+
record = build_record(spec, bytes, created_at)
|
|
98
|
+
@manifest = @manifest.with(record)
|
|
99
|
+
rewrite(@archive.gem_pairs + [[record.filename, bytes]])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def rewrite(gem_pairs)
|
|
103
|
+
@archive.write([[Manifest::FILENAME, @manifest.to_json]] + gem_pairs)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def survivors_excluding(dropped)
|
|
107
|
+
names = dropped.map(&:filename)
|
|
108
|
+
@archive.gem_pairs.reject { |pair| names.include?(pair.first) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_record(spec, bytes, created_at)
|
|
112
|
+
Manifest::Record.new(
|
|
113
|
+
name: spec.name,
|
|
114
|
+
version: spec.version.to_s,
|
|
115
|
+
platform: spec.platform.to_s,
|
|
116
|
+
created_at: created_at,
|
|
117
|
+
sha256: Manifest.digest(bytes),
|
|
118
|
+
encrypted: false,
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def matching_records(reference)
|
|
123
|
+
case reference
|
|
124
|
+
in GemReference::AnyVersion[name:]
|
|
125
|
+
@manifest.records.select { |r| r.name == name }
|
|
126
|
+
in GemReference::SpecificVersion[name:, version:]
|
|
127
|
+
@manifest.records.select { |r| r.name == name && r.version == version.to_s }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def raise_if_duplicate(spec)
|
|
132
|
+
return unless @manifest.find(spec.name, spec.version.to_s, spec.platform.to_s)
|
|
133
|
+
|
|
134
|
+
raise Vault::DuplicateGemError,
|
|
135
|
+
"Gem already in vault: #{spec.name}-#{spec.version} (#{spec.platform})"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def verify(bytes, record)
|
|
139
|
+
return bytes if Manifest.digest(bytes) == record.sha256
|
|
140
|
+
|
|
141
|
+
raise Vault::Error, "Integrity check failed for #{record.filename}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def timestamp
|
|
145
|
+
Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/gemvault/vault.rb
CHANGED
|
@@ -1,193 +1,87 @@
|
|
|
1
|
-
require "
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "tempfile"
|
|
5
|
-
require_relative "gem_entry"
|
|
6
|
-
require_relative "gem_reference"
|
|
1
|
+
require "forwardable"
|
|
2
|
+
require_relative "vault_session"
|
|
7
3
|
|
|
8
4
|
module Gemvault
|
|
9
|
-
#
|
|
5
|
+
# The public vault interface. Delegates storage to a backend chosen by file
|
|
6
|
+
# format: a Dbvault (SQLite) for existing SQLite files, a Tarvault (tarball)
|
|
7
|
+
# otherwise. New vaults are Tarvaults. Only the selected backend is loaded,
|
|
8
|
+
# so the tar path never requires sqlite3.
|
|
10
9
|
class Vault
|
|
10
|
+
extend VaultSession
|
|
11
|
+
extend Forwardable
|
|
12
|
+
|
|
11
13
|
class Error < StandardError; end
|
|
12
14
|
class NotFoundError < Error; end
|
|
13
15
|
class DuplicateGemError < Error; end
|
|
14
16
|
class InvalidGemError < Error; end
|
|
17
|
+
class UnsupportedVersionError < Error; end
|
|
18
|
+
class ReadOnlyError < Error; end
|
|
15
19
|
|
|
16
|
-
SCHEMA_VERSION = "1".freeze
|
|
17
20
|
SQLITE_MAGIC = "SQLite format 3#{0.chr}".freeze
|
|
21
|
+
TAR_MAGIC = "ustar".freeze
|
|
22
|
+
TAR_MAGIC_OFFSET = 257
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def self.open(path, **opts, &block)
|
|
22
|
-
raise ArgumentError, "#{name}.open requires a block" unless block
|
|
23
|
-
|
|
24
|
-
vault = new(path, **opts)
|
|
25
|
-
begin
|
|
26
|
-
yield vault
|
|
27
|
-
ensure
|
|
28
|
-
vault.close
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def initialize(path, create: false)
|
|
33
|
-
@path = File.expand_path(path)
|
|
34
|
-
create ? create_vault! : open_vault!
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def add(gem_path)
|
|
38
|
-
gem_path = File.expand_path(gem_path)
|
|
39
|
-
raise NotFoundError, "Gem file not found: #{gem_path}" unless File.file?(gem_path)
|
|
40
|
-
|
|
41
|
-
spec = load_gem_spec(gem_path)
|
|
42
|
-
raise_if_duplicate(spec)
|
|
43
|
-
insert_gem(gem_path, spec)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def remove(reference)
|
|
47
|
-
case reference
|
|
48
|
-
in GemReference::AnyVersion[name:]
|
|
49
|
-
@db.execute("DELETE FROM gems WHERE name = ?", [name])
|
|
50
|
-
in GemReference::SpecificVersion[name:, version:]
|
|
51
|
-
@db.execute(
|
|
52
|
-
"DELETE FROM gems WHERE name = ? AND version = ?",
|
|
53
|
-
[name, version.to_s],
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
@db.changes
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def gem_data(name, version, platform: "ruby")
|
|
60
|
-
row = @db.execute(
|
|
61
|
-
"SELECT data FROM gems WHERE name = ? AND version = ? AND platform = ?",
|
|
62
|
-
[name, version, platform],
|
|
63
|
-
).first
|
|
64
|
-
|
|
65
|
-
raise NotFoundError, "Gem not found: #{name}-#{version} (#{platform})" unless row
|
|
66
|
-
|
|
67
|
-
row["data"]
|
|
68
|
-
end
|
|
24
|
+
CURRENT_FORMAT = 2
|
|
25
|
+
MIN_READABLE_FORMAT = 1
|
|
69
26
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
27
|
+
def_delegators :@backend,
|
|
28
|
+
:add, :remove, :gem_data, :gem_entries, :specs,
|
|
29
|
+
:spec_from_blob, :with_gem_file, :size, :close, :closed?,
|
|
30
|
+
:path, :format_version
|
|
73
31
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
"SELECT name, version, platform, created_at FROM gems ORDER BY name, version",
|
|
77
|
-
).map { |row| GemEntry.new(**row.transform_keys(&:to_sym)) }
|
|
78
|
-
end
|
|
32
|
+
def self.assert_readable!(version, path)
|
|
33
|
+
return if version.between?(MIN_READABLE_FORMAT, CURRENT_FORMAT)
|
|
79
34
|
|
|
80
|
-
|
|
81
|
-
|
|
35
|
+
raise UnsupportedVersionError,
|
|
36
|
+
"Vault #{path} is format #{version}; this gemvault reads up to #{CURRENT_FORMAT}. Upgrade gemvault."
|
|
82
37
|
end
|
|
83
38
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
39
|
+
def self.backend_for(path, create:)
|
|
40
|
+
return build_tarvault(path, create: true) if create
|
|
41
|
+
raise NotFoundError, "Vault not found: #{path}" unless File.exist?(path)
|
|
87
42
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
ensure
|
|
94
|
-
tmpfile.close unless tmpfile.closed?
|
|
95
|
-
tmpfile.unlink
|
|
43
|
+
case container_kind(path)
|
|
44
|
+
when :sqlite then build_dbvault(path)
|
|
45
|
+
when :tar then build_tarvault(path, create: false)
|
|
46
|
+
else
|
|
47
|
+
raise Error, "Unrecognized vault format: #{path} (not a Dbvault or Tarvault; it may require a newer gemvault)"
|
|
96
48
|
end
|
|
97
49
|
end
|
|
98
50
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
private
|
|
51
|
+
def self.container_kind(path)
|
|
52
|
+
return :sqlite if sqlite?(path)
|
|
53
|
+
return :tar if tar?(path)
|
|
106
54
|
|
|
107
|
-
|
|
108
|
-
raise Error, "Vault already exists: #{@path}" if File.exist?(@path)
|
|
109
|
-
|
|
110
|
-
@db = new_database
|
|
111
|
-
create_schema
|
|
55
|
+
:unknown
|
|
112
56
|
end
|
|
113
57
|
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
validate_sqlite!
|
|
118
|
-
@db = new_database
|
|
58
|
+
def self.sqlite?(path)
|
|
59
|
+
File.exist?(path) && File.binread(path, SQLITE_MAGIC.bytesize) == SQLITE_MAGIC
|
|
119
60
|
end
|
|
120
61
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
db.results_as_hash = true
|
|
124
|
-
db
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def load_gem_spec(gem_path)
|
|
128
|
-
Gem::Package.new(gem_path).spec
|
|
129
|
-
rescue StandardError => e
|
|
130
|
-
raise InvalidGemError, "Invalid gem file #{gem_path}: #{e.message}"
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def raise_if_duplicate(spec)
|
|
134
|
-
existing = @db.execute(
|
|
135
|
-
"SELECT 1 FROM gems WHERE name = ? AND version = ? AND platform = ?",
|
|
136
|
-
[spec.name, spec.version.to_s, spec.platform.to_s],
|
|
137
|
-
)
|
|
138
|
-
return if existing.empty?
|
|
62
|
+
def self.tar?(path)
|
|
63
|
+
return false unless File.exist?(path)
|
|
139
64
|
|
|
140
|
-
|
|
141
|
-
|
|
65
|
+
header = File.binread(path, TAR_MAGIC_OFFSET + TAR_MAGIC.bytesize)
|
|
66
|
+
header.to_s[TAR_MAGIC_OFFSET, TAR_MAGIC.bytesize] == TAR_MAGIC
|
|
142
67
|
end
|
|
143
68
|
|
|
144
|
-
def
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
69
|
+
def self.build_dbvault(path)
|
|
70
|
+
require_relative "dbvault"
|
|
71
|
+
Dbvault.new(path)
|
|
72
|
+
rescue LoadError => e
|
|
73
|
+
raise Error,
|
|
74
|
+
"#{path} is a legacy SQLite vault; it needs the sqlite3 gem (#{e.message}). " \
|
|
75
|
+
"Install sqlite3, or upgrade the vault with a gemvault that includes it."
|
|
150
76
|
end
|
|
151
77
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
tmpfile.write(data)
|
|
156
|
-
tmpfile.close
|
|
157
|
-
tmpfile
|
|
78
|
+
def self.build_tarvault(path, create:)
|
|
79
|
+
require_relative "tarvault"
|
|
80
|
+
Tarvault.new(path, create: create)
|
|
158
81
|
end
|
|
159
82
|
|
|
160
|
-
def
|
|
161
|
-
@
|
|
162
|
-
CREATE TABLE metadata (
|
|
163
|
-
key TEXT PRIMARY KEY,
|
|
164
|
-
value TEXT NOT NULL
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
CREATE TABLE gems (
|
|
168
|
-
name TEXT NOT NULL,
|
|
169
|
-
version TEXT NOT NULL,
|
|
170
|
-
platform TEXT NOT NULL DEFAULT 'ruby',
|
|
171
|
-
data BLOB NOT NULL,
|
|
172
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
173
|
-
PRIMARY KEY (name, version, platform)
|
|
174
|
-
);
|
|
175
|
-
SQL
|
|
176
|
-
|
|
177
|
-
@db.execute(
|
|
178
|
-
"INSERT INTO metadata (key, value) VALUES (?, ?)",
|
|
179
|
-
["vault_version", SCHEMA_VERSION],
|
|
180
|
-
)
|
|
181
|
-
@db.execute(
|
|
182
|
-
"INSERT INTO metadata (key, value) VALUES (?, ?)",
|
|
183
|
-
["created_at", Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")],
|
|
184
|
-
)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def validate_sqlite!
|
|
188
|
-
return if File.binread(@path, SQLITE_MAGIC.bytesize) == SQLITE_MAGIC
|
|
189
|
-
|
|
190
|
-
raise Error, "Not a valid vault file (not SQLite): #{@path}"
|
|
83
|
+
def initialize(path, create: false)
|
|
84
|
+
@backend = self.class.backend_for(File.expand_path(path), create: create)
|
|
191
85
|
end
|
|
192
86
|
end
|
|
193
87
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Gemvault
|
|
2
|
+
# Class-method mixin giving vault types a block-scoped `open` that news up
|
|
3
|
+
# an instance, yields it, and guarantees `close`.
|
|
4
|
+
module VaultSession
|
|
5
|
+
def open(path, **opts)
|
|
6
|
+
raise ArgumentError, "#{name}.open requires a block" unless block_given?
|
|
7
|
+
|
|
8
|
+
vault = new(path, **opts)
|
|
9
|
+
begin
|
|
10
|
+
yield vault
|
|
11
|
+
ensure
|
|
12
|
+
vault.close
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "vault"
|
|
3
|
+
require_relative "deprecation"
|
|
4
|
+
|
|
5
|
+
module Gemvault
|
|
6
|
+
# Migrates a vault to the current storage format by reading it through its
|
|
7
|
+
# existing backend and rewriting it through the current-format writer, then
|
|
8
|
+
# atomically swapping the file into place. Preserves each gem's created_at.
|
|
9
|
+
class VaultUpgrade
|
|
10
|
+
# The from/to/gem-count summary of an upgrade; also detects an
|
|
11
|
+
# already-current (no-op) vault.
|
|
12
|
+
Plan = Struct.new(:from_version, :to_version, :gem_count, keyword_init: true) do
|
|
13
|
+
def no_op?
|
|
14
|
+
from_version == to_version
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_s
|
|
18
|
+
"format #{from_version} -> #{to_version}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(path, backup: true)
|
|
23
|
+
@path = File.expand_path(path)
|
|
24
|
+
@backup = backup
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def plan
|
|
28
|
+
Deprecation.silence do
|
|
29
|
+
Vault.open(@path) do |vault|
|
|
30
|
+
Plan.new(from_version: vault.format_version, to_version: Vault::CURRENT_FORMAT, gem_count: vault.size)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
summary = plan
|
|
37
|
+
return summary if summary.no_op?
|
|
38
|
+
|
|
39
|
+
backup! if @backup
|
|
40
|
+
rebuild(summary.gem_count)
|
|
41
|
+
summary
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def backup!
|
|
47
|
+
backup_path = "#{@path}.bak"
|
|
48
|
+
if File.exist?(backup_path)
|
|
49
|
+
raise Vault::Error, "Backup already exists: #{backup_path} (remove it or pass --no-backup)"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
FileUtils.cp(@path, backup_path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rebuild(expected_count)
|
|
56
|
+
tmp = "#{@path}.upgrading"
|
|
57
|
+
FileUtils.rm_f(tmp)
|
|
58
|
+
copy_current_format(tmp)
|
|
59
|
+
verify_count!(tmp, expected_count)
|
|
60
|
+
File.rename(tmp, @path)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def copy_current_format(tmp)
|
|
64
|
+
Deprecation.silence do
|
|
65
|
+
Vault.open(@path) do |old|
|
|
66
|
+
target = Vault.new(tmp, create: true)
|
|
67
|
+
old.gem_entries.each { |entry| copy_gem(old, target, entry) }
|
|
68
|
+
target.close
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def copy_gem(old, target, entry)
|
|
74
|
+
old.with_gem_file(entry.name, entry.version, platform: entry.platform) do |gem_path|
|
|
75
|
+
target.add(gem_path, created_at: entry.created_at)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def verify_count!(tmp, expected_count)
|
|
80
|
+
actual = Vault.open(tmp, &:size)
|
|
81
|
+
return if actual == expected_count
|
|
82
|
+
|
|
83
|
+
FileUtils.rm_f(tmp)
|
|
84
|
+
raise Vault::Error, "Upgrade verification failed: expected #{expected_count} gems, got #{actual}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/gemvault/version.rb
CHANGED