scint 0.1.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.
- checksums.yaml +7 -0
- data/FEATURES.md +13 -0
- data/README.md +216 -0
- data/bin/bundler-vs-scint +233 -0
- data/bin/scint +35 -0
- data/bin/scint-io-summary +46 -0
- data/bin/scint-syscall-trace +41 -0
- data/lib/bundler/setup.rb +5 -0
- data/lib/bundler.rb +168 -0
- data/lib/scint/cache/layout.rb +131 -0
- data/lib/scint/cache/metadata_store.rb +75 -0
- data/lib/scint/cache/prewarm.rb +192 -0
- data/lib/scint/cli/add.rb +85 -0
- data/lib/scint/cli/cache.rb +316 -0
- data/lib/scint/cli/exec.rb +150 -0
- data/lib/scint/cli/install.rb +1047 -0
- data/lib/scint/cli/remove.rb +60 -0
- data/lib/scint/cli.rb +77 -0
- data/lib/scint/commands/exec.rb +17 -0
- data/lib/scint/commands/install.rb +17 -0
- data/lib/scint/credentials.rb +153 -0
- data/lib/scint/debug/io_trace.rb +218 -0
- data/lib/scint/debug/sampler.rb +138 -0
- data/lib/scint/downloader/fetcher.rb +113 -0
- data/lib/scint/downloader/pool.rb +112 -0
- data/lib/scint/errors.rb +63 -0
- data/lib/scint/fs.rb +119 -0
- data/lib/scint/gem/extractor.rb +86 -0
- data/lib/scint/gem/package.rb +62 -0
- data/lib/scint/gemfile/dependency.rb +30 -0
- data/lib/scint/gemfile/editor.rb +93 -0
- data/lib/scint/gemfile/parser.rb +275 -0
- data/lib/scint/index/cache.rb +166 -0
- data/lib/scint/index/client.rb +301 -0
- data/lib/scint/index/parser.rb +142 -0
- data/lib/scint/installer/extension_builder.rb +264 -0
- data/lib/scint/installer/linker.rb +226 -0
- data/lib/scint/installer/planner.rb +140 -0
- data/lib/scint/installer/preparer.rb +207 -0
- data/lib/scint/lockfile/parser.rb +251 -0
- data/lib/scint/lockfile/writer.rb +178 -0
- data/lib/scint/platform.rb +71 -0
- data/lib/scint/progress.rb +579 -0
- data/lib/scint/resolver/provider.rb +230 -0
- data/lib/scint/resolver/resolver.rb +249 -0
- data/lib/scint/runtime/exec.rb +141 -0
- data/lib/scint/runtime/setup.rb +45 -0
- data/lib/scint/scheduler.rb +392 -0
- data/lib/scint/source/base.rb +46 -0
- data/lib/scint/source/git.rb +92 -0
- data/lib/scint/source/path.rb +70 -0
- data/lib/scint/source/rubygems.rb +79 -0
- data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
- data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
- data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
- data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
- data/lib/scint/vendor/pub_grub/package.rb +43 -0
- data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
- data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
- data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
- data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
- data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
- data/lib/scint/vendor/pub_grub/term.rb +105 -0
- data/lib/scint/vendor/pub_grub/version.rb +3 -0
- data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
- data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
- data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
- data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
- data/lib/scint/vendor/pub_grub.rb +32 -0
- data/lib/scint/worker_pool.rb +114 -0
- data/lib/scint.rb +87 -0
- metadata +116 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "../fs"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
|
|
9
|
+
module Scint
|
|
10
|
+
module Downloader
|
|
11
|
+
class Fetcher
|
|
12
|
+
MAX_REDIRECTS = 5
|
|
13
|
+
|
|
14
|
+
def initialize(credentials: nil)
|
|
15
|
+
@connections = {} # "host:port" => Net::HTTP
|
|
16
|
+
@mutex = Thread::Mutex.new
|
|
17
|
+
@credentials = credentials
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Download a single file from uri to dest_path.
|
|
21
|
+
# Streams to a temp file then atomically renames.
|
|
22
|
+
# Returns { path: dest_path, size: bytes }
|
|
23
|
+
def fetch(uri, dest_path, checksum: nil)
|
|
24
|
+
uri = URI.parse(uri) unless uri.is_a?(URI)
|
|
25
|
+
FS.mkdir_p(File.dirname(dest_path))
|
|
26
|
+
|
|
27
|
+
tmp_path = "#{dest_path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
28
|
+
size = 0
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
redirect_count = 0
|
|
32
|
+
current_uri = uri
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
http = connection_for(current_uri)
|
|
36
|
+
request = Net::HTTP::Get.new(current_uri.request_uri)
|
|
37
|
+
request["Accept-Encoding"] = "identity"
|
|
38
|
+
@credentials&.apply!(request, current_uri)
|
|
39
|
+
|
|
40
|
+
response = http.request(request)
|
|
41
|
+
|
|
42
|
+
case response
|
|
43
|
+
when Net::HTTPSuccess
|
|
44
|
+
File.open(tmp_path, "wb") do |f|
|
|
45
|
+
body = response.body
|
|
46
|
+
f.write(body)
|
|
47
|
+
size = body.bytesize
|
|
48
|
+
end
|
|
49
|
+
break
|
|
50
|
+
when Net::HTTPRedirection
|
|
51
|
+
redirect_count += 1
|
|
52
|
+
raise NetworkError, "Too many redirects for #{uri}" if redirect_count > MAX_REDIRECTS
|
|
53
|
+
location = response["location"]
|
|
54
|
+
current_uri = URI.parse(location)
|
|
55
|
+
next
|
|
56
|
+
else
|
|
57
|
+
raise NetworkError, "HTTP #{response.code} for #{uri}: #{response.message}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if checksum
|
|
62
|
+
actual = Digest::SHA256.file(tmp_path).hexdigest
|
|
63
|
+
unless actual == checksum
|
|
64
|
+
File.delete(tmp_path) if File.exist?(tmp_path)
|
|
65
|
+
raise NetworkError, "Checksum mismatch for #{uri}: expected #{checksum}, got #{actual}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
File.rename(tmp_path, dest_path)
|
|
70
|
+
{ path: dest_path, size: size }
|
|
71
|
+
rescue StandardError
|
|
72
|
+
File.delete(tmp_path) if File.exist?(tmp_path)
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Close all persistent connections.
|
|
78
|
+
def close
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@connections.each_value do |http|
|
|
81
|
+
http.finish if http.started?
|
|
82
|
+
rescue StandardError
|
|
83
|
+
# ignore close errors
|
|
84
|
+
end
|
|
85
|
+
@connections.clear
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def connection_for(uri)
|
|
92
|
+
key = "#{uri.host}:#{uri.port}:#{uri.scheme}"
|
|
93
|
+
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
http = @connections[key]
|
|
96
|
+
if http && http.started?
|
|
97
|
+
return http
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
101
|
+
http.use_ssl = (uri.scheme == "https")
|
|
102
|
+
http.open_timeout = 10
|
|
103
|
+
http.read_timeout = 30
|
|
104
|
+
http.keep_alive_timeout = 30
|
|
105
|
+
http.start
|
|
106
|
+
|
|
107
|
+
@connections[key] = http
|
|
108
|
+
http
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fetcher"
|
|
4
|
+
require_relative "../worker_pool"
|
|
5
|
+
require_relative "../platform"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Scint
|
|
9
|
+
module Downloader
|
|
10
|
+
class Pool
|
|
11
|
+
MAX_RETRIES = 3
|
|
12
|
+
BACKOFF_BASE = 0.5 # seconds
|
|
13
|
+
|
|
14
|
+
attr_reader :size
|
|
15
|
+
|
|
16
|
+
def initialize(size: nil, on_progress: nil, credentials: nil)
|
|
17
|
+
@size = size || [Platform.cpu_count * 2, 50].min
|
|
18
|
+
@on_progress = on_progress
|
|
19
|
+
@credentials = credentials
|
|
20
|
+
@fetchers = {} # thread_id => Fetcher
|
|
21
|
+
@fetcher_mutex = Thread::Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Download a single URI to dest_path with retry logic.
|
|
25
|
+
# Returns { path:, size: }
|
|
26
|
+
def download(uri, dest_path, checksum: nil)
|
|
27
|
+
retries = 0
|
|
28
|
+
begin
|
|
29
|
+
fetcher = thread_fetcher
|
|
30
|
+
fetcher.fetch(uri, dest_path, checksum: checksum)
|
|
31
|
+
rescue NetworkError, Errno::ECONNRESET, Errno::ECONNREFUSED,
|
|
32
|
+
Errno::ETIMEDOUT, Net::ReadTimeout, Net::OpenTimeout,
|
|
33
|
+
SocketError, IOError => e
|
|
34
|
+
retries += 1
|
|
35
|
+
if retries <= MAX_RETRIES
|
|
36
|
+
sleep(BACKOFF_BASE * (2**(retries - 1)))
|
|
37
|
+
# Reset connection on retry
|
|
38
|
+
reset_thread_fetcher
|
|
39
|
+
retry
|
|
40
|
+
end
|
|
41
|
+
raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Download multiple items concurrently.
|
|
46
|
+
# items: [{ uri:, dest:, spec:, checksum: }]
|
|
47
|
+
# Returns array of { spec:, path:, size:, error: }
|
|
48
|
+
def download_batch(items)
|
|
49
|
+
results = []
|
|
50
|
+
result_mutex = Thread::Mutex.new
|
|
51
|
+
|
|
52
|
+
pool = WorkerPool.new(@size, name: "download")
|
|
53
|
+
remaining = items.size
|
|
54
|
+
|
|
55
|
+
done = Thread::Queue.new
|
|
56
|
+
|
|
57
|
+
pool.start do |item|
|
|
58
|
+
result = download_one(item)
|
|
59
|
+
result_mutex.synchronize { results << result }
|
|
60
|
+
@on_progress&.call(result)
|
|
61
|
+
done.push(true)
|
|
62
|
+
result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
items.each { |item| pool.enqueue(item) }
|
|
66
|
+
|
|
67
|
+
remaining.times { done.pop }
|
|
68
|
+
pool.stop
|
|
69
|
+
|
|
70
|
+
results
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Close all connections across all threads.
|
|
74
|
+
def close
|
|
75
|
+
@fetcher_mutex.synchronize do
|
|
76
|
+
@fetchers.each_value(&:close)
|
|
77
|
+
@fetchers.clear
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def download_one(item)
|
|
84
|
+
uri = item[:uri]
|
|
85
|
+
dest = item[:dest]
|
|
86
|
+
spec = item[:spec]
|
|
87
|
+
checksum = item[:checksum]
|
|
88
|
+
|
|
89
|
+
result = download(uri, dest, checksum: checksum)
|
|
90
|
+
{ spec: spec, path: result[:path], size: result[:size], error: nil }
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
{ spec: spec, path: nil, size: 0, error: e }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Each thread gets its own Fetcher for connection reuse without locking.
|
|
96
|
+
def thread_fetcher
|
|
97
|
+
tid = Thread.current.object_id
|
|
98
|
+
@fetcher_mutex.synchronize do
|
|
99
|
+
@fetchers[tid] ||= Fetcher.new(credentials: @credentials)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def reset_thread_fetcher
|
|
104
|
+
tid = Thread.current.object_id
|
|
105
|
+
@fetcher_mutex.synchronize do
|
|
106
|
+
old = @fetchers.delete(tid)
|
|
107
|
+
old&.close
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/scint/errors.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scint
|
|
4
|
+
class BundlerError < StandardError
|
|
5
|
+
def status_code
|
|
6
|
+
1
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class GemfileError < BundlerError
|
|
11
|
+
def status_code
|
|
12
|
+
4
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class LockfileError < BundlerError
|
|
17
|
+
def status_code
|
|
18
|
+
5
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ResolveError < BundlerError
|
|
23
|
+
def status_code
|
|
24
|
+
6
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class NetworkError < BundlerError
|
|
29
|
+
def status_code
|
|
30
|
+
7
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class InstallError < BundlerError
|
|
35
|
+
def status_code
|
|
36
|
+
8
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class ExtensionBuildError < InstallError
|
|
41
|
+
def status_code
|
|
42
|
+
9
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class PermissionError < BundlerError
|
|
47
|
+
def status_code
|
|
48
|
+
10
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class PlatformError < BundlerError
|
|
53
|
+
def status_code
|
|
54
|
+
11
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class CacheError < BundlerError
|
|
59
|
+
def status_code
|
|
60
|
+
12
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/scint/fs.rb
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Scint
|
|
7
|
+
module FS
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Cache of directories we've already ensured exist, to avoid repeated syscalls.
|
|
11
|
+
@mkdir_cache = {}
|
|
12
|
+
@mkdir_mutex = Thread::Mutex.new
|
|
13
|
+
|
|
14
|
+
def mkdir_p(path)
|
|
15
|
+
path = path.to_s
|
|
16
|
+
return if @mkdir_cache[path]
|
|
17
|
+
|
|
18
|
+
# Do the filesystem call outside the cache mutex so unrelated directory
|
|
19
|
+
# creation can proceed in parallel across worker threads.
|
|
20
|
+
FileUtils.mkdir_p(path)
|
|
21
|
+
@mkdir_mutex.synchronize { @mkdir_cache[path] = true }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# APFS clonefile (CoW copy). Falls back to hardlink, then regular copy.
|
|
25
|
+
def clonefile(src, dst)
|
|
26
|
+
src = src.to_s
|
|
27
|
+
dst = dst.to_s
|
|
28
|
+
mkdir_p(File.dirname(dst))
|
|
29
|
+
|
|
30
|
+
# Try APFS clonefile via cp -c (macOS)
|
|
31
|
+
if Platform.macos?
|
|
32
|
+
return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fallback: hardlink
|
|
36
|
+
begin
|
|
37
|
+
File.link(src, dst)
|
|
38
|
+
return
|
|
39
|
+
rescue SystemCallError
|
|
40
|
+
# cross-device or unsupported
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Final fallback: regular copy
|
|
44
|
+
FileUtils.cp(src, dst)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Recursively hardlink all files from src_dir into dst_dir.
|
|
48
|
+
# Directory structure is recreated; files are hardlinked.
|
|
49
|
+
def hardlink_tree(src_dir, dst_dir)
|
|
50
|
+
src_dir = src_dir.to_s
|
|
51
|
+
dst_dir = dst_dir.to_s
|
|
52
|
+
raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
|
|
53
|
+
mkdir_p(dst_dir)
|
|
54
|
+
|
|
55
|
+
queue = [[src_dir, dst_dir]]
|
|
56
|
+
until queue.empty?
|
|
57
|
+
src_root, dst_root = queue.shift
|
|
58
|
+
|
|
59
|
+
Dir.each_child(src_root) do |entry|
|
|
60
|
+
src_path = File.join(src_root, entry)
|
|
61
|
+
dst_path = File.join(dst_root, entry)
|
|
62
|
+
stat = File.lstat(src_path)
|
|
63
|
+
|
|
64
|
+
if stat.directory?
|
|
65
|
+
mkdir_p(dst_path)
|
|
66
|
+
queue << [src_path, dst_path]
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
mkdir_p(File.dirname(dst_path))
|
|
71
|
+
begin
|
|
72
|
+
File.link(src_path, dst_path)
|
|
73
|
+
rescue SystemCallError
|
|
74
|
+
FileUtils.cp(src_path, dst_path)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Atomic move: rename with cross-device fallback.
|
|
81
|
+
def atomic_move(src, dst)
|
|
82
|
+
src = src.to_s
|
|
83
|
+
dst = dst.to_s
|
|
84
|
+
mkdir_p(File.dirname(dst))
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
File.rename(src, dst)
|
|
88
|
+
rescue Errno::EXDEV
|
|
89
|
+
# Cross-device: copy then remove
|
|
90
|
+
tmp = "#{dst}.#{Process.pid}.#{Thread.current.object_id}"
|
|
91
|
+
if File.directory?(src)
|
|
92
|
+
FileUtils.cp_r(src, tmp)
|
|
93
|
+
else
|
|
94
|
+
FileUtils.cp(src, tmp)
|
|
95
|
+
end
|
|
96
|
+
File.rename(tmp, dst)
|
|
97
|
+
FileUtils.rm_rf(src)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Create a temporary directory, yield it, clean up on error.
|
|
102
|
+
def with_tempdir(prefix = "scint")
|
|
103
|
+
dir = Dir.mktmpdir(prefix)
|
|
104
|
+
yield dir
|
|
105
|
+
rescue StandardError
|
|
106
|
+
FileUtils.rm_rf(dir) if dir && File.exist?(dir)
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Write to a temp file then atomically rename into place.
|
|
111
|
+
def atomic_write(path, content)
|
|
112
|
+
path = path.to_s
|
|
113
|
+
mkdir_p(File.dirname(path))
|
|
114
|
+
tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
115
|
+
File.binwrite(tmp, content)
|
|
116
|
+
File.rename(tmp, path)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "../fs"
|
|
7
|
+
|
|
8
|
+
module Scint
|
|
9
|
+
module GemPkg
|
|
10
|
+
class Extractor
|
|
11
|
+
# Extract data.tar.gz contents into dest_dir.
|
|
12
|
+
# Primary: shell out to system tar for speed.
|
|
13
|
+
# Fallback: pure Ruby extraction.
|
|
14
|
+
def extract(data_tar_gz_path, dest_dir)
|
|
15
|
+
FS.mkdir_p(dest_dir)
|
|
16
|
+
|
|
17
|
+
if system_tar_available?
|
|
18
|
+
extract_with_system_tar(data_tar_gz_path, dest_dir)
|
|
19
|
+
else
|
|
20
|
+
extract_with_ruby(data_tar_gz_path, dest_dir)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
dest_dir
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def system_tar_available?
|
|
29
|
+
@system_tar_available = system("tar", "--version", [:out, :err] => File::NULL) if @system_tar_available.nil?
|
|
30
|
+
@system_tar_available
|
|
31
|
+
rescue Errno::ENOENT
|
|
32
|
+
@system_tar_available = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extract_with_system_tar(data_tar_gz_path, dest_dir)
|
|
36
|
+
result = system("tar", "xzf", data_tar_gz_path, "-C", dest_dir)
|
|
37
|
+
return if result
|
|
38
|
+
|
|
39
|
+
# If system tar fails, fall back to Ruby
|
|
40
|
+
extract_with_ruby(data_tar_gz_path, dest_dir)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def extract_with_ruby(data_tar_gz_path, dest_dir)
|
|
44
|
+
File.open(data_tar_gz_path, "rb") do |file|
|
|
45
|
+
gz = Zlib::GzipReader.new(file)
|
|
46
|
+
tar = ::Gem::Package::TarReader.new(gz)
|
|
47
|
+
|
|
48
|
+
tar.each do |entry|
|
|
49
|
+
dest_path = File.join(dest_dir, entry.full_name)
|
|
50
|
+
|
|
51
|
+
# Security: prevent path traversal
|
|
52
|
+
unless safe_path?(dest_dir, dest_path)
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if entry.directory?
|
|
57
|
+
FS.mkdir_p(dest_path)
|
|
58
|
+
elsif entry.symlink?
|
|
59
|
+
# Only follow symlinks that stay inside dest_dir
|
|
60
|
+
link_target = File.expand_path(entry.header.linkname, File.dirname(dest_path))
|
|
61
|
+
if safe_path?(dest_dir, link_target)
|
|
62
|
+
FS.mkdir_p(File.dirname(dest_path))
|
|
63
|
+
File.symlink(entry.header.linkname, dest_path)
|
|
64
|
+
end
|
|
65
|
+
elsif entry.file?
|
|
66
|
+
FS.mkdir_p(File.dirname(dest_path))
|
|
67
|
+
File.open(dest_path, "wb") do |f|
|
|
68
|
+
while (chunk = entry.read(16384))
|
|
69
|
+
f.write(chunk)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
File.chmod(entry.header.mode, dest_path) if entry.header.mode
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Ensure dest_path is inside base_dir (prevent directory traversal).
|
|
79
|
+
def safe_path?(base_dir, dest_path)
|
|
80
|
+
expanded = File.expand_path(dest_path)
|
|
81
|
+
expanded_base = File.expand_path(base_dir)
|
|
82
|
+
expanded.start_with?("#{expanded_base}/") || expanded == expanded_base
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/package"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require_relative "extractor"
|
|
7
|
+
require_relative "../fs"
|
|
8
|
+
|
|
9
|
+
module Scint
|
|
10
|
+
module GemPkg
|
|
11
|
+
class Package
|
|
12
|
+
# Read gemspec from a .gem file without full extraction.
|
|
13
|
+
# Returns a Gem::Specification.
|
|
14
|
+
def read_metadata(gem_path)
|
|
15
|
+
File.open(gem_path, "rb") do |io|
|
|
16
|
+
tar = ::Gem::Package::TarReader.new(io)
|
|
17
|
+
tar.each do |entry|
|
|
18
|
+
if entry.full_name == "metadata.gz"
|
|
19
|
+
gz = Zlib::GzipReader.new(StringIO.new(entry.read))
|
|
20
|
+
return ::Gem::Specification.from_yaml(gz.read)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
raise InstallError, "No metadata.gz found in #{gem_path}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Single-pass extraction: reads the .gem TAR once, extracts both
|
|
28
|
+
# metadata.gz (gemspec) and data.tar.gz (files) in one pass.
|
|
29
|
+
# Returns { gemspec: Gem::Specification, extracted_path: dest_dir }
|
|
30
|
+
def extract(gem_path, dest_dir)
|
|
31
|
+
FS.mkdir_p(dest_dir)
|
|
32
|
+
gemspec = nil
|
|
33
|
+
data_tar_gz = nil
|
|
34
|
+
|
|
35
|
+
File.open(gem_path, "rb") do |io|
|
|
36
|
+
tar = ::Gem::Package::TarReader.new(io)
|
|
37
|
+
tar.each do |entry|
|
|
38
|
+
case entry.full_name
|
|
39
|
+
when "metadata.gz"
|
|
40
|
+
gz = Zlib::GzipReader.new(StringIO.new(entry.read))
|
|
41
|
+
gemspec = ::Gem::Specification.from_yaml(gz.read)
|
|
42
|
+
when "data.tar.gz"
|
|
43
|
+
# Write data.tar.gz to a temp file for extraction
|
|
44
|
+
tmp = File.join(dest_dir, ".data.tar.gz.tmp")
|
|
45
|
+
File.open(tmp, "wb") { |f| f.write(entry.read) }
|
|
46
|
+
data_tar_gz = tmp
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise InstallError, "No metadata.gz in #{gem_path}" unless gemspec
|
|
52
|
+
|
|
53
|
+
if data_tar_gz
|
|
54
|
+
Extractor.new.extract(data_tar_gz, dest_dir)
|
|
55
|
+
File.delete(data_tar_gz) if File.exist?(data_tar_gz)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
{ gemspec: gemspec, extracted_path: dest_dir }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scint
|
|
4
|
+
module Gemfile
|
|
5
|
+
# Represents a single gem dependency declared in a Gemfile.
|
|
6
|
+
# This is an intermediate type used during Gemfile parsing;
|
|
7
|
+
# the resolver and installer use the top-level Scint::Dependency struct.
|
|
8
|
+
class Dependency
|
|
9
|
+
attr_reader :name, :version_reqs, :groups, :platforms, :require_paths, :source_options
|
|
10
|
+
|
|
11
|
+
def initialize(name, version_reqs: [], groups: [:default], platforms: [],
|
|
12
|
+
require_paths: nil, source_options: {})
|
|
13
|
+
@name = name.to_s.freeze
|
|
14
|
+
@version_reqs = version_reqs.empty? ? [">= 0"] : version_reqs
|
|
15
|
+
@groups = groups.map(&:to_sym)
|
|
16
|
+
@platforms = platforms.map(&:to_sym)
|
|
17
|
+
@require_paths = require_paths
|
|
18
|
+
@source_options = source_options
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
if version_reqs == [">= 0"]
|
|
23
|
+
name
|
|
24
|
+
else
|
|
25
|
+
"#{name} (#{version_reqs.join(", ")})"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Scint
|
|
6
|
+
module Gemfile
|
|
7
|
+
# Lightweight text editor for common Gemfile dependency updates.
|
|
8
|
+
#
|
|
9
|
+
# Handles single-line `gem` declarations, which is enough for the
|
|
10
|
+
# fast-path workflows used by `scint add` and `scint remove`.
|
|
11
|
+
class Editor
|
|
12
|
+
def initialize(path = "Gemfile")
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add(name, requirement: nil, group: nil, source: nil, git: nil, path: nil)
|
|
17
|
+
content = read
|
|
18
|
+
line = build_line(
|
|
19
|
+
name,
|
|
20
|
+
requirement: requirement,
|
|
21
|
+
group: group,
|
|
22
|
+
source: source,
|
|
23
|
+
git: git,
|
|
24
|
+
path: path,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
updated = false
|
|
28
|
+
out_lines = content.lines.map do |l|
|
|
29
|
+
if gem_line_for?(l, name)
|
|
30
|
+
updated = true
|
|
31
|
+
"#{line}\n"
|
|
32
|
+
else
|
|
33
|
+
l
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless updated
|
|
38
|
+
out_lines << "\n" unless out_lines.empty? || out_lines.last.end_with?("\n\n")
|
|
39
|
+
out_lines << "#{line}\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
write(out_lines.join)
|
|
43
|
+
updated ? :updated : :added
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remove(name)
|
|
47
|
+
content = read
|
|
48
|
+
removed = false
|
|
49
|
+
|
|
50
|
+
out_lines = content.lines.reject do |line|
|
|
51
|
+
match = gem_line_for?(line, name)
|
|
52
|
+
removed ||= match
|
|
53
|
+
match
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
write(out_lines.join)
|
|
57
|
+
removed
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def read
|
|
63
|
+
unless File.exist?(@path)
|
|
64
|
+
raise GemfileError, "Gemfile not found at #{@path}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
File.read(@path)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write(content)
|
|
71
|
+
File.write(@path, content)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def gem_line_for?(line, name)
|
|
75
|
+
line.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["'](?:\s|,|$)/)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_line(name, requirement:, group:, source:, git:, path:)
|
|
79
|
+
parts = ["gem #{name.inspect}"]
|
|
80
|
+
parts << requirement.inspect if requirement && !requirement.empty?
|
|
81
|
+
|
|
82
|
+
opts = []
|
|
83
|
+
opts << "group: :#{group}" if group
|
|
84
|
+
opts << "source: #{source.inspect}" if source
|
|
85
|
+
opts << "git: #{git.inspect}" if git
|
|
86
|
+
opts << "path: #{path.inspect}" if path
|
|
87
|
+
|
|
88
|
+
parts << opts.join(", ") unless opts.empty?
|
|
89
|
+
parts.join(", ")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|