scint 0.6.0 → 0.7.1

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.
@@ -13,9 +13,10 @@ module Scint
13
13
  # Lookup order (first match wins):
14
14
  # 1. Inline credentials in the request URI itself (user:pass@host)
15
15
  # 2. Credentials registered at runtime (from Gemfile source: URIs)
16
- # 3. Scint config: $XDG_CONFIG_HOME/scint/credentials
17
- # 4. Bundler config: ~/.bundle/config
18
- # 5. Environment variables (BUNDLE_HOST__NAME format)
16
+ # 3. Bundler local config: $BUNDLE_APP_CONFIG/config or ./.bundle/config
17
+ # 4. Scint config: $XDG_CONFIG_HOME/scint/credentials
18
+ # 5. Bundler global config: ~/.bundle/config
19
+ # 6. Environment variables (BUNDLE_HOST__NAME / BUNDLE_HTTPS://... format)
19
20
  #
20
21
  # All config files use Bundler's key format:
21
22
  # BUNDLE_PKGS__SHOPIFY__IO: "token:secret"
@@ -80,7 +81,7 @@ module Scint
80
81
  end
81
82
 
82
83
  # 2–5. Registered + config files + env
83
- auth = lookup_host(uri.host)
84
+ auth = lookup_uri(uri)
84
85
  return nil unless auth
85
86
 
86
87
  user, password = auth.split(":", 2)
@@ -99,27 +100,35 @@ module Scint
99
100
 
100
101
  private
101
102
 
102
- def lookup_host(host)
103
- return nil unless host
103
+ def lookup_uri(uri)
104
+ return nil unless uri&.host
104
105
 
105
106
  # 2. Runtime-registered (from Gemfile inline URIs)
106
- registered = @mutex.synchronize { @registered[host] }
107
+ registered = @mutex.synchronize { @registered[uri.host] }
107
108
  return registered if registered
108
109
 
109
- # 3–4. Config files (scint, then bundler)
110
- key = self.class.key_for_host(host)
111
- val = @file_config[key]
112
- return val if val
110
+ # 3–5. Config files (bundler local, scint, bundler global)
111
+ keys = self.class.keys_for_uri_lookup(uri)
112
+ keys.each do |key|
113
+ val = @file_config[key]
114
+ return val if val
115
+ end
116
+
117
+ # 6. Environment variable
118
+ keys.each do |key|
119
+ val = ENV[key]
120
+ return val if val
121
+ end
113
122
 
114
- # 5. Environment variable
115
- ENV[key]
123
+ nil
116
124
  end
117
125
 
118
126
  def load_config_files
119
127
  config = {}
120
128
  # Load in reverse priority (later overrides earlier)
121
- load_yaml_into(config, bundler_config_path)
129
+ load_yaml_into(config, bundler_global_config_path)
122
130
  load_yaml_into(config, scint_credentials_path)
131
+ load_yaml_into(config, bundler_local_config_path)
123
132
  config
124
133
  end
125
134
 
@@ -137,7 +146,17 @@ module Scint
137
146
  File.join(xdg, "scint", "credentials")
138
147
  end
139
148
 
140
- def bundler_config_path
149
+ def bundler_local_config_path
150
+ app_config = ENV["BUNDLE_APP_CONFIG"]
151
+ dir = if app_config && !app_config.empty?
152
+ app_config
153
+ else
154
+ File.join(Dir.pwd, ".bundle")
155
+ end
156
+ File.join(dir, "config")
157
+ end
158
+
159
+ def bundler_global_config_path
141
160
  File.join(Dir.home, ".bundle", "config")
142
161
  end
143
162
 
@@ -149,5 +168,49 @@ module Scint
149
168
  key.upcase!
150
169
  "BUNDLE_#{key}"
151
170
  end
171
+
172
+ def self.key_for_uri_string(uri_string)
173
+ key = uri_string.to_s.dup
174
+ key.gsub!(".", "__")
175
+ key.gsub!("-", "___")
176
+ key.upcase!
177
+ "BUNDLE_#{key}"
178
+ end
179
+
180
+ def self.key_for_source_uri(uri)
181
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
182
+ keys_for_uri_lookup(uri).find { |key| key != key_for_host(uri.host) }
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
187
+ def self.keys_for_uri_lookup(uri)
188
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
189
+ keys = []
190
+
191
+ normalized = "#{uri.scheme || 'https'}://#{uri.host}"
192
+ default_port = (uri.scheme == "http" ? 80 : 443)
193
+ normalized += ":#{uri.port}" if uri.port && uri.port != default_port
194
+
195
+ path = uri.path.to_s
196
+ path = "/" if path.empty?
197
+ path = "/#{path}" unless path.start_with?("/")
198
+ path += "/" unless path.end_with?("/")
199
+
200
+ segments = path.split("/").reject(&:empty?)
201
+ candidate_paths = ["/"]
202
+ unless segments.empty?
203
+ 1.upto(segments.length) do |i|
204
+ candidate_paths << "/#{segments.first(i).join('/')}/"
205
+ end
206
+ end
207
+
208
+ candidate_paths.reverse_each do |candidate_path|
209
+ keys << key_for_uri_string("#{normalized}#{candidate_path}")
210
+ end
211
+
212
+ keys << key_for_host(uri.host) if uri.host
213
+ keys.compact.uniq
214
+ end
152
215
  end
153
216
  end
@@ -110,7 +110,9 @@ module Scint
110
110
 
111
111
  next unless container.method_defined?(original_name) || container.private_method_defined?(original_name)
112
112
 
113
- container.send(:alias_method, method_name, original_name)
113
+ with_silenced_warnings do
114
+ container.send(:alias_method, method_name, original_name)
115
+ end
114
116
  container.send(:remove_method, original_name)
115
117
 
116
118
  visibility = entry[:visibility]
@@ -146,12 +148,21 @@ module Scint
146
148
  visibility = method_visibility(container, method_name)
147
149
 
148
150
  container.send(:alias_method, original_name, method_name)
149
- container.send(:define_method, method_name) do |*args, **kwargs, &block|
150
- Scint::Debug::IOTrace.log(op_name, args: args, kwargs: kwargs)
151
- if kwargs.empty?
152
- send(original_name, *args, &block)
153
- else
154
- send(original_name, *args, **kwargs, &block)
151
+ if container.instance_methods(false).include?(method_name) ||
152
+ container.private_instance_methods(false).include?(method_name) ||
153
+ container.protected_instance_methods(false).include?(method_name)
154
+ container.send(:remove_method, method_name)
155
+ elsif container.method_defined?(method_name) || container.private_method_defined?(method_name) || container.protected_method_defined?(method_name)
156
+ container.send(:undef_method, method_name)
157
+ end
158
+ with_silenced_warnings do
159
+ container.send(:define_method, method_name) do |*args, **kwargs, &block|
160
+ Scint::Debug::IOTrace.log(op_name, args: args, kwargs: kwargs)
161
+ if kwargs.empty?
162
+ send(original_name, *args, &block)
163
+ else
164
+ send(original_name, *args, **kwargs, &block)
165
+ end
155
166
  end
156
167
  end
157
168
 
@@ -173,6 +184,14 @@ module Scint
173
184
  nil
174
185
  end
175
186
 
187
+ def with_silenced_warnings
188
+ verbose = $VERBOSE
189
+ $VERBOSE = nil
190
+ yield
191
+ ensure
192
+ $VERBOSE = verbose
193
+ end
194
+
176
195
  def disable_unlocked
177
196
  return unless @enabled || @log_io
178
197
 
@@ -54,7 +54,13 @@ module Scint
54
54
  current_uri = URI.parse(location)
55
55
  next
56
56
  else
57
- raise NetworkError, "HTTP #{response.code} for #{uri}: #{response.message}"
57
+ raise NetworkError.new(
58
+ http_error_message(current_uri, response),
59
+ uri: current_uri.to_s,
60
+ http_status: response.code.to_i,
61
+ response_headers: response_headers_hash(response),
62
+ response_body: response.body.to_s,
63
+ )
58
64
  end
59
65
  end
60
66
 
@@ -108,6 +114,24 @@ module Scint
108
114
  http
109
115
  end
110
116
  end
117
+
118
+ def http_error_message(uri, response)
119
+ message = "HTTP #{response.code} for #{uri}: #{response.message}"
120
+ body = response.body.to_s
121
+ return message if body.empty?
122
+
123
+ excerpt = body.gsub(/\s+/, " ").strip
124
+ return message if excerpt.empty?
125
+
126
+ excerpt = "#{excerpt[0, 277]}..." if excerpt.length > 280
127
+ "#{message} -- #{excerpt}"
128
+ end
129
+
130
+ def response_headers_hash(response)
131
+ headers = {}
132
+ response.each_header { |k, v| headers[k] = v }
133
+ headers
134
+ end
111
135
  end
112
136
  end
113
137
  end
@@ -4,41 +4,59 @@ require_relative "fetcher"
4
4
  require_relative "../worker_pool"
5
5
  require_relative "../platform"
6
6
  require_relative "../errors"
7
+ require "uri"
7
8
 
8
9
  module Scint
9
10
  module Downloader
10
11
  class Pool
11
12
  MAX_RETRIES = 3
12
13
  BACKOFF_BASE = 0.5 # seconds
14
+ DEFAULT_PER_HOST_LIMIT = 4
13
15
 
14
16
  attr_reader :size
15
17
 
16
- def initialize(size: nil, on_progress: nil, credentials: nil)
18
+ def initialize(size: nil, on_progress: nil, credentials: nil, per_host_limit: DEFAULT_PER_HOST_LIMIT)
17
19
  @size = size || [Platform.cpu_count * 2, 50].min
18
20
  @on_progress = on_progress
19
21
  @credentials = credentials
22
+ @per_host_limit = [per_host_limit.to_i, 1].max
20
23
  @fetchers = {} # thread_id => Fetcher
21
24
  @fetcher_mutex = Thread::Mutex.new
25
+ @host_slots = Hash.new(0)
26
+ @host_waiters = {}
27
+ @host_mutex = Thread::Mutex.new
22
28
  end
23
29
 
24
30
  # Download a single URI to dest_path with retry logic.
25
31
  # Returns { path:, size: }
26
32
  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
33
+ with_host_slot(uri) do
34
+ retries = 0
35
+ begin
36
+ fetcher = thread_fetcher
37
+ fetcher.fetch(uri, dest_path, checksum: checksum)
38
+ rescue NetworkError, Errno::ECONNRESET, Errno::ECONNREFUSED,
39
+ Errno::ETIMEDOUT, Net::ReadTimeout, Net::OpenTimeout,
40
+ SocketError, IOError => e
41
+ retries += 1
42
+ if retries <= MAX_RETRIES
43
+ sleep(BACKOFF_BASE * (2**(retries - 1)))
44
+ # Reset connection on retry
45
+ reset_thread_fetcher
46
+ retry
47
+ end
48
+ if e.is_a?(NetworkError)
49
+ raise NetworkError.new(
50
+ "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}",
51
+ uri: (e.uri || uri.to_s),
52
+ http_status: e.http_status,
53
+ response_headers: e.response_headers,
54
+ response_body: e.response_body,
55
+ )
56
+ end
57
+
58
+ raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
40
59
  end
41
- raise NetworkError, "Failed to download #{uri} after #{MAX_RETRIES} retries: #{e.message}"
42
60
  end
43
61
  end
44
62
 
@@ -107,6 +125,40 @@ module Scint
107
125
  old&.close
108
126
  end
109
127
  end
128
+
129
+ def with_host_slot(uri)
130
+ key = host_slot_key(uri)
131
+ return yield unless key
132
+
133
+ waiter = nil
134
+ @host_mutex.synchronize do
135
+ waiter = (@host_waiters[key] ||= Thread::ConditionVariable.new)
136
+ while @host_slots[key] >= @per_host_limit
137
+ waiter.wait(@host_mutex)
138
+ end
139
+ @host_slots[key] += 1
140
+ end
141
+
142
+ begin
143
+ yield
144
+ ensure
145
+ @host_mutex.synchronize do
146
+ @host_slots[key] -= 1 if @host_slots[key] > 0
147
+ waiter.broadcast
148
+ end
149
+ end
150
+ end
151
+
152
+ def host_slot_key(uri)
153
+ parsed = uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
154
+ return nil unless parsed.host
155
+
156
+ scheme = parsed.scheme || "https"
157
+ port = parsed.port || (scheme == "https" ? 443 : 80)
158
+ "#{scheme}://#{parsed.host}:#{port}"
159
+ rescue URI::InvalidURIError
160
+ nil
161
+ end
110
162
  end
111
163
  end
112
164
  end
data/lib/scint/errors.rb CHANGED
@@ -26,6 +26,16 @@ module Scint
26
26
  end
27
27
 
28
28
  class NetworkError < BundlerError
29
+ attr_reader :uri, :http_status, :response_headers, :response_body
30
+
31
+ def initialize(message = nil, uri: nil, http_status: nil, response_headers: nil, response_body: nil)
32
+ super(message)
33
+ @uri = uri
34
+ @http_status = http_status
35
+ @response_headers = response_headers
36
+ @response_body = response_body
37
+ end
38
+
29
39
  def status_code
30
40
  7
31
41
  end
data/lib/scint/fs.rb CHANGED
@@ -21,28 +21,75 @@ module Scint
21
21
  @mkdir_mutex.synchronize { @mkdir_cache[path] = true }
22
22
  end
23
23
 
24
+ # Detect best file copy strategy once per process.
25
+ # Returns :reflink, :hardlink, or :copy.
26
+ @copy_strategy = nil
27
+ @copy_strategy_mutex = Thread::Mutex.new
28
+
29
+ def detect_copy_strategy(src_dir, dst_dir)
30
+ @copy_strategy_mutex.synchronize do
31
+ return @copy_strategy if @copy_strategy
32
+
33
+ @copy_strategy = _probe_copy_strategy(src_dir, dst_dir)
34
+ end
35
+ end
36
+
37
+ def _probe_copy_strategy(src_dir, dst_dir)
38
+ # Create a temp file in src to test against dst
39
+ probe_src = File.join(src_dir, ".scint_probe_#{$$}")
40
+ probe_dst = File.join(dst_dir, ".scint_probe_#{$$}")
41
+ begin
42
+ File.write(probe_src, "x")
43
+ mkdir_p(dst_dir)
44
+
45
+ if Platform.macos?
46
+ if system("cp", "-c", probe_src, probe_dst, [:out, :err] => File::NULL)
47
+ return :reflink
48
+ end
49
+ File.delete(probe_dst) if File.exist?(probe_dst)
50
+ elsif Platform.linux?
51
+ if system("cp", "--reflink=always", probe_src, probe_dst, [:out, :err] => File::NULL)
52
+ return :reflink
53
+ end
54
+ File.delete(probe_dst) if File.exist?(probe_dst)
55
+ end
56
+
57
+ begin
58
+ File.link(probe_src, probe_dst)
59
+ return :hardlink
60
+ rescue SystemCallError
61
+ File.delete(probe_dst) if File.exist?(probe_dst)
62
+ end
63
+
64
+ :copy
65
+ ensure
66
+ File.delete(probe_src) if File.exist?(probe_src)
67
+ File.delete(probe_dst) if File.exist?(probe_dst)
68
+ end
69
+ rescue StandardError
70
+ :copy
71
+ end
72
+
24
73
  # APFS clonefile (CoW copy). Falls back to hardlink, then regular copy.
25
74
  def clonefile(src, dst)
26
75
  src = src.to_s
27
76
  dst = dst.to_s
28
77
  mkdir_p(File.dirname(dst))
29
78
 
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
- # Try Linux reflink copy-on-write where supported (btrfs/xfs/etc).
36
- if Platform.linux?
37
- return if system("cp", "--reflink=always", src, dst, [:out, :err] => File::NULL)
38
- end
39
-
40
- # Fallback: hardlink
41
- begin
42
- File.link(src, dst)
43
- return
44
- rescue SystemCallError
45
- # cross-device or unsupported
79
+ case detect_copy_strategy(File.dirname(src), File.dirname(dst))
80
+ when :reflink
81
+ if Platform.macos?
82
+ return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
83
+ elsif Platform.linux?
84
+ return if system("cp", "--reflink=always", src, dst, [:out, :err] => File::NULL)
85
+ end
86
+ when :hardlink
87
+ begin
88
+ File.link(src, dst)
89
+ return
90
+ rescue SystemCallError
91
+ # fall through to copy
92
+ end
46
93
  end
47
94
 
48
95
  # Final fallback: regular copy
@@ -58,26 +105,164 @@ module Scint
58
105
  raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
59
106
  mkdir_p(dst_dir)
60
107
 
61
- # Fast path on macOS/APFS: copy-on-write clone of full tree.
62
- if Platform.macos?
63
- src_contents = File.join(src_dir, ".")
64
- return if system("cp", "-cR", src_contents, dst_dir, [:out, :err] => File::NULL)
65
- end
108
+ strategy = detect_copy_strategy(src_dir, dst_dir)
66
109
 
67
- # Fast path on Linux filesystems with reflink support.
68
- if Platform.linux?
110
+ if strategy == :reflink
69
111
  src_contents = File.join(src_dir, ".")
70
- return if system("cp", "--reflink=always", "-R", src_contents, dst_dir, [:out, :err] => File::NULL)
112
+ if Platform.macos?
113
+ return if system("cp", "-cR", src_contents, dst_dir, [:out, :err] => File::NULL)
114
+ elsif Platform.linux?
115
+ return if system("cp", "--reflink=always", "-R", src_contents, dst_dir, [:out, :err] => File::NULL)
116
+ end
71
117
  end
72
118
 
73
119
  hardlink_tree(src_dir, dst_dir)
74
120
  end
75
121
 
122
+ # Materialize a tree using a manifest to avoid directory scans.
123
+ # Manifest entries must be hashes with "path" and "type" keys.
124
+ # Uses the fastest available file copy strategy (reflink > hardlink > copy).
125
+ def materialize_from_manifest(src_dir, dst_dir, entries)
126
+ src_dir = src_dir.to_s
127
+ dst_dir = dst_dir.to_s
128
+ entries = Array(entries)
129
+ raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
130
+ mkdir_p(dst_dir)
131
+
132
+ strategy = detect_copy_strategy(src_dir, dst_dir)
133
+
134
+ entries.each do |entry|
135
+ rel = entry["path"].to_s
136
+ next if rel.empty? || rel.start_with?("/") || rel.include?("..")
137
+
138
+ src_path = File.join(src_dir, rel)
139
+ dst_path = File.join(dst_dir, rel)
140
+
141
+ case entry["type"]
142
+ when "dir"
143
+ mkdir_p(dst_path)
144
+ when "symlink"
145
+ mkdir_p(File.dirname(dst_path))
146
+ next if File.exist?(dst_path) || File.symlink?(dst_path)
147
+
148
+ target = File.readlink(src_path)
149
+ begin
150
+ File.symlink(target, dst_path)
151
+ rescue Errno::EEXIST
152
+ next
153
+ end
154
+ else
155
+ mkdir_p(File.dirname(dst_path))
156
+ next if File.exist?(dst_path)
157
+
158
+ begin
159
+ _link_or_copy(src_path, dst_path, strategy)
160
+ rescue Errno::EEXIST
161
+ next
162
+ rescue SystemCallError
163
+ next if File.exist?(dst_path)
164
+ raise
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ # Fast file link/copy using a pre-detected strategy (no per-file probing).
171
+ def _link_or_copy(src, dst, strategy)
172
+ case strategy
173
+ when :reflink
174
+ if Platform.macos?
175
+ return if system("cp", "-c", src, dst, [:out, :err] => File::NULL)
176
+ elsif Platform.linux?
177
+ return if system("cp", "--reflink=always", src, dst, [:out, :err] => File::NULL)
178
+ end
179
+ # reflink failed for this file, try hardlink
180
+ begin
181
+ File.link(src, dst)
182
+ return
183
+ rescue SystemCallError; end
184
+ FileUtils.cp(src, dst)
185
+ when :hardlink
186
+ begin
187
+ File.link(src, dst)
188
+ return
189
+ rescue SystemCallError; end
190
+ FileUtils.cp(src, dst)
191
+ else
192
+ FileUtils.cp(src, dst)
193
+ end
194
+ end
195
+
196
+ LINKER_SCRIPT = File.expand_path("linker.sh", __dir__).freeze
197
+
198
+ # Bulk-link cached gem directories into dst_parent.
199
+ # Opens one helper process (linker.sh) and writes gem basenames to its
200
+ # stdin. The helper probes the fastest FS strategy once then applies it
201
+ # to every gem.
202
+ def bulk_link_gems(src_parent, dst_parent, gem_names)
203
+ src_parent = src_parent.to_s
204
+ dst_parent = dst_parent.to_s
205
+ gem_names = Array(gem_names)
206
+ return 0 if gem_names.empty?
207
+
208
+ mkdir_p(dst_parent)
209
+
210
+ IO.popen(["/bin/bash", LINKER_SCRIPT], "w") do |io|
211
+ io.puts src_parent
212
+ io.puts dst_parent
213
+ gem_names.each { |name| io.puts name }
214
+ end
215
+
216
+ gem_names.size
217
+ end
218
+
219
+ # Clone many source directories into one destination parent directory.
220
+ # This is significantly faster than one process per gem on large warm
221
+ # installs because it batches cp invocations while preserving CoW/reflink.
222
+ # Returns the number of source trees requested.
223
+ def clone_many_trees(src_dirs, dst_parent, chunk_size: 64)
224
+ dst_parent = dst_parent.to_s
225
+ mkdir_p(dst_parent)
226
+
227
+ sources = Array(src_dirs).map(&:to_s).uniq
228
+ sources.select! { |src| Dir.exist?(src) }
229
+ return 0 if sources.empty?
230
+
231
+ copied = 0
232
+ strategy = sources.first ? detect_copy_strategy(sources.first, dst_parent) : :copy
233
+
234
+ sources.each_slice([chunk_size.to_i, 1].max) do |slice|
235
+ pending = slice.reject do |src|
236
+ Dir.exist?(File.join(dst_parent, File.basename(src)))
237
+ end
238
+ next if pending.empty?
239
+
240
+ ok = false
241
+ if strategy == :reflink
242
+ if Platform.macos?
243
+ ok = system("cp", "-cR", *pending, dst_parent, [:out, :err] => File::NULL)
244
+ elsif Platform.linux?
245
+ ok = system("cp", "--reflink=always", "-R", *pending, dst_parent, [:out, :err] => File::NULL)
246
+ end
247
+ end
248
+
249
+ unless ok
250
+ pending.each do |src|
251
+ clone_tree(src, File.join(dst_parent, File.basename(src)))
252
+ end
253
+ end
254
+
255
+ copied += pending.length
256
+ end
257
+
258
+ copied
259
+ end
260
+
76
261
  # Recursively hardlink all files from src_dir into dst_dir.
77
262
  # Directory structure is recreated; files are hardlinked.
78
263
  def hardlink_tree(src_dir, dst_dir)
79
- src_dir = src_dir.to_s
80
- dst_dir = dst_dir.to_s
264
+ src_dir = File.expand_path(src_dir.to_s)
265
+ dst_dir = File.expand_path(dst_dir.to_s)
81
266
  raise Errno::ENOENT, src_dir unless Dir.exist?(src_dir)
82
267
  mkdir_p(dst_dir)
83
268
 
@@ -88,6 +273,10 @@ module Scint
88
273
  Dir.each_child(src_root) do |entry|
89
274
  src_path = File.join(src_root, entry)
90
275
  dst_path = File.join(dst_root, entry)
276
+
277
+ # Guard against recursive copy when destination is nested under source.
278
+ next if dst_dir == src_path || dst_dir.start_with?("#{src_path}/")
279
+
91
280
  stat = File.lstat(src_path)
92
281
 
93
282
  if stat.directory?
@@ -17,7 +17,9 @@ module Scint
17
17
  tar.each do |entry|
18
18
  if entry.full_name == "metadata.gz"
19
19
  gz = Zlib::GzipReader.new(StringIO.new(entry.read))
20
- return ::Gem::Specification.from_yaml(gz.read)
20
+ yaml = gz.read
21
+ yaml.force_encoding("UTF-8") if yaml.encoding == Encoding::US_ASCII
22
+ return ::Gem::Specification.from_yaml(yaml)
21
23
  end
22
24
  end
23
25
  end
@@ -38,7 +40,9 @@ module Scint
38
40
  case entry.full_name
39
41
  when "metadata.gz"
40
42
  gz = Zlib::GzipReader.new(StringIO.new(entry.read))
41
- gemspec = ::Gem::Specification.from_yaml(gz.read)
43
+ yaml = gz.read
44
+ yaml.force_encoding("UTF-8") if yaml.encoding == Encoding::US_ASCII
45
+ gemspec = ::Gem::Specification.from_yaml(yaml)
42
46
  when "data.tar.gz"
43
47
  # Write data.tar.gz to a temp file for extraction
44
48
  tmp = File.join(dest_dir, ".data.tar.gz.tmp")