paquette 0.3.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/paquette/gem_server/gem_repacker/rooted_package.rb +14 -6
- data/lib/paquette/gem_server/gem_repacker.rb +44 -1
- data/lib/paquette/gem_server/personalizer.rb +5 -2
- data/lib/paquette/npm_repacker.rb +12 -2
- data/lib/paquette/npm_server/personalizer.rb +4 -1
- data/lib/paquette/version.rb +1 -1
- data/rbi/paquette.rbi +40 -10
- data/sig/paquette.rbs +36 -8
- data/test/gem_server/gem_repacker_test.rb +107 -0
- data/test/npm_repacker_test.rb +40 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cad055b11059ab61aa4f330936f3f6eb8bf43256599c7cfbe836f52e5cd00ac5
|
|
4
|
+
data.tar.gz: 1571bb0d09b5c88d917ec64b16ae1161f317f372b81f0b76b24b7be3fc01fcdc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4291dd74555ff94d70856692972e3a20f8343bce143e4acab9662ae439043da6419c643e2bb299c7508ef5b5997797d273933b7e78931775789627c174beafa0
|
|
7
|
+
data.tar.gz: 99ddfc489182b81a1e3d6ff8c876fe4a6e94720363352794f34c3688ccc0bee87bad0498c6648978bb235651fdc219d316a8898ef2cc413b8d2f0d9264b01739
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## Unreleased
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- Give every file in a repacked gem the mode the source gem recorded for it. The modes used to come from the unpacked copy on disk, which carried the 0600 of the Tempfile each `.rb` file is rewritten through, and the serving process's umask for everything else — so the same gem repacked to different bytes on servers with different umasks. A file Paquette injects gets 0644; one that replaces a shipped file keeps that file's mode
|
|
6
|
+
- Replace a file the source gem shipped read-only instead of failing the repack
|
|
7
|
+
- Include `GemRepacker::FORMAT_VERSION` in the personalizer's cache key, so personalized gems cached by an earlier repacker are rebuilt rather than served. Every personalized gem's checksum changes with this release, and a consumer whose lockfile records one will need to refresh it
|
|
8
|
+
- Keep the mode of a file in an npm tarball that `files:` replaces, instead of writing it 0644 — replacing a bin script used to leave it not executable
|
|
9
|
+
- Include `NpmRepacker::FORMAT_VERSION` in the npm personalizer's cache key, so tarballs cached by an earlier repacker are rebuilt rather than served. The key changes with this release, so the npm cache starts cold once; a tarball's bytes change only where a replaced file was executable
|
|
10
|
+
|
|
3
11
|
## 0.3.0
|
|
4
12
|
|
|
5
13
|
- Ship signature files that name every external type. A type sord could not resolve — `Rack::Request`, `Gem::Specification`, `File::Stat` and the like — used to land in `rbi/paquette.rbi` and `sig/paquette.rbs` as a `SORD_ERROR_` constant, which no type checker can read; those types are now root-anchored in the YARD tags and come through by name, and every constant carries a type of its own
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
require "rubygems/package"
|
|
2
2
|
|
|
3
3
|
# Gem::Package that reads the files it packs from a directory of the
|
|
4
|
-
# caller's choosing instead of from the working directory, silently
|
|
4
|
+
# caller's choosing instead of from the working directory, silently, and
|
|
5
|
+
# records the modes it is given instead of the ones on disk.
|
|
5
6
|
# `gem build` only works because the CLI chdir's into the unpacked gem
|
|
6
7
|
# first — and chdir is process-global, so a server cannot do that. Only the
|
|
7
8
|
# two methods that reach outside are overridden; the .gem format itself
|
|
@@ -17,11 +18,14 @@ class Paquette::GemServer::GemRepacker::RootedPackage < Gem::Package
|
|
|
17
18
|
# @param file_name [String] where the finished gem is written
|
|
18
19
|
# @param build_time [Time] the timestamp stamped into the archive — see
|
|
19
20
|
# BuildTime for why this is a block around the build, not an argument
|
|
21
|
+
# @param modes [Hash{String => Integer}] the tar mode of every file in
|
|
22
|
+
# spec.files
|
|
20
23
|
# @return [String] file_name
|
|
21
|
-
def self.build(spec, root, file_name, build_time:)
|
|
24
|
+
def self.build(spec, root, file_name, build_time:, modes:)
|
|
22
25
|
Paquette::GemServer::GemRepacker::BuildTime.with(build_time) do
|
|
23
26
|
package = new(file_name, nil)
|
|
24
27
|
package.root = root
|
|
28
|
+
package.modes = modes
|
|
25
29
|
package.spec = spec
|
|
26
30
|
package.build(true)
|
|
27
31
|
end
|
|
@@ -32,8 +36,12 @@ class Paquette::GemServer::GemRepacker::RootedPackage < Gem::Package
|
|
|
32
36
|
# @return [String]
|
|
33
37
|
attr_accessor :root
|
|
34
38
|
|
|
35
|
-
#
|
|
36
|
-
|
|
39
|
+
# @return [Hash{String => Integer}]
|
|
40
|
+
attr_accessor :modes
|
|
41
|
+
|
|
42
|
+
# The RubyGems original with @root joined onto every path it looks at, and
|
|
43
|
+
# each mode taken from @modes rather than from the file's stat. The names
|
|
44
|
+
# written into the tar stay relative, which is what a .gem holds.
|
|
37
45
|
#
|
|
38
46
|
# @param tar [::Gem::Package::TarWriter]
|
|
39
47
|
# @return [void]
|
|
@@ -43,12 +51,12 @@ class Paquette::GemServer::GemRepacker::RootedPackage < Gem::Package
|
|
|
43
51
|
stat = File.lstat(source)
|
|
44
52
|
|
|
45
53
|
if stat.symlink?
|
|
46
|
-
tar.add_symlink file, File.readlink(source),
|
|
54
|
+
tar.add_symlink file, File.readlink(source), @modes.fetch(file)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
next unless stat.file?
|
|
50
58
|
|
|
51
|
-
tar.add_file_simple file,
|
|
59
|
+
tar.add_file_simple file, @modes.fetch(file), stat.size do |dst_io|
|
|
52
60
|
File.open source, "rb" do |src_io|
|
|
53
61
|
copy_stream(src_io, dst_io)
|
|
54
62
|
end
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "digest"
|
|
6
6
|
require "tmpdir"
|
|
7
7
|
require "tempfile"
|
|
8
|
+
require "zlib"
|
|
8
9
|
require "measurometer"
|
|
9
10
|
|
|
10
11
|
# Rewrites the contents of a .gem into a new, byte-reproducible .gem —
|
|
@@ -13,6 +14,19 @@ class Paquette::GemServer::GemRepacker
|
|
|
13
14
|
autoload :BuildTime, "#{__dir__}/gem_repacker/build_time"
|
|
14
15
|
autoload :RootedPackage, "#{__dir__}/gem_repacker/rooted_package"
|
|
15
16
|
|
|
17
|
+
# Bumped whenever the same source gem and the same arguments would repack
|
|
18
|
+
# into different bytes, so a cache keyed on the inputs can include it.
|
|
19
|
+
# 2: file modes come from the source gem rather than the unpacked copy.
|
|
20
|
+
#
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
FORMAT_VERSION = 2
|
|
23
|
+
|
|
24
|
+
# What `gem build` records for a plain file on a umask-022 machine, given
|
|
25
|
+
# to a file the source gem did not have.
|
|
26
|
+
#
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
INJECTED_FILE_MODE = 0o100644
|
|
29
|
+
|
|
16
30
|
# One-shot convenience for {#initialize} + {#repack}.
|
|
17
31
|
#
|
|
18
32
|
# @param gem_path [String]
|
|
@@ -42,6 +56,7 @@ class Paquette::GemServer::GemRepacker
|
|
|
42
56
|
@into = into
|
|
43
57
|
@temp_dir = nil
|
|
44
58
|
@unpacked_gem_dir = nil
|
|
59
|
+
@entry_modes = nil
|
|
45
60
|
end
|
|
46
61
|
|
|
47
62
|
# Four subprocess-and-disk stages, each timed separately: a repack that
|
|
@@ -80,6 +95,7 @@ class Paquette::GemServer::GemRepacker
|
|
|
80
95
|
Measurometer.instrument("paquette.gem_repacker.gem_unpack") do
|
|
81
96
|
package.extract_files(@gem_dir)
|
|
82
97
|
end
|
|
98
|
+
@entry_modes = source_entry_modes
|
|
83
99
|
rescue Gem::Exception => e
|
|
84
100
|
raise "Failed to unpack gem: #{@gem_path}. Error: #{e.message}"
|
|
85
101
|
end
|
|
@@ -91,12 +107,38 @@ class Paquette::GemServer::GemRepacker
|
|
|
91
107
|
rb_files.each { |rb_file| process_ruby_file(rb_file) }
|
|
92
108
|
end
|
|
93
109
|
|
|
110
|
+
# The mode of every entry in the source gem's data.tar.gz, as its packer
|
|
111
|
+
# wrote it. The repack writes these back rather than what is on disk:
|
|
112
|
+
# extraction masks each mode with this process's umask, and the .rb files
|
|
113
|
+
# are rewritten through a Tempfile created 0600, so the disk says more
|
|
114
|
+
# about the server than about the gem.
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash{String => Integer}] entry name => mode
|
|
117
|
+
def source_entry_modes
|
|
118
|
+
modes = {}
|
|
119
|
+
File.open(@gem_path, "rb") do |io|
|
|
120
|
+
Gem::Package::TarReader.new(io).each do |entry|
|
|
121
|
+
next unless entry.full_name == "data.tar.gz"
|
|
122
|
+
Zlib::GzipReader.wrap(entry) do |gz|
|
|
123
|
+
Gem::Package::TarReader.new(gz).each { |file| modes[file.full_name] = file.header.mode }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
modes
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A file that replaces one the gem shipped keeps that file's mode. Removed
|
|
131
|
+
# before it is written, because the one it replaces may have been shipped
|
|
132
|
+
# read-only.
|
|
133
|
+
#
|
|
94
134
|
# @return [void]
|
|
95
135
|
def inject_files
|
|
96
136
|
@files.each do |file_path, content|
|
|
97
137
|
full_path = File.join(@gem_dir, file_path)
|
|
98
138
|
FileUtils.mkdir_p(File.dirname(full_path))
|
|
139
|
+
FileUtils.rm_f(full_path)
|
|
99
140
|
File.write(full_path, content)
|
|
141
|
+
@entry_modes[file_path] ||= INJECTED_FILE_MODE
|
|
100
142
|
end
|
|
101
143
|
end
|
|
102
144
|
|
|
@@ -160,7 +202,8 @@ class Paquette::GemServer::GemRepacker
|
|
|
160
202
|
Measurometer.instrument("paquette.gem_repacker.gem_build") do
|
|
161
203
|
Paquette::GemServer::GemRepacker::RootedPackage.build(
|
|
162
204
|
repacked_spec(original_spec), @gem_dir, final_gem_path,
|
|
163
|
-
build_time: original_spec.date
|
|
205
|
+
build_time: original_spec.date,
|
|
206
|
+
modes: @entry_modes
|
|
164
207
|
)
|
|
165
208
|
end
|
|
166
209
|
|
|
@@ -287,12 +287,15 @@ class Paquette::GemServer::Personalizer < SimpleDelegator
|
|
|
287
287
|
File.join(@cache_dir, "#{gem_name}-#{version}-#{digest}.plain")
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
-
# Everything this personalizer would write into a gem,
|
|
291
|
-
#
|
|
290
|
+
# Everything this personalizer would write into a gem, and the repacker
|
|
291
|
+
# format it writes it with, as one short hash. Stable across processes, so
|
|
292
|
+
# a restart does not orphan the cache; a new repacker format does, on
|
|
293
|
+
# purpose.
|
|
292
294
|
#
|
|
293
295
|
# @return [String]
|
|
294
296
|
def personalization_digest
|
|
295
297
|
@personalization_digest ||= Digest::SHA256.hexdigest([
|
|
298
|
+
Paquette::GemServer::GemRepacker::FORMAT_VERSION,
|
|
296
299
|
@license_key,
|
|
297
300
|
@magic_comment_replacements.sort.inspect,
|
|
298
301
|
@files.sort.inspect,
|
|
@@ -15,6 +15,14 @@ class Paquette::NpmRepacker
|
|
|
15
15
|
# Raised for a marker or replacement spanning several lines.
|
|
16
16
|
class MultilineReplacement < StandardError; end
|
|
17
17
|
|
|
18
|
+
# Bumped whenever the same source tarball and the same arguments would
|
|
19
|
+
# repack into different bytes, so a cache keyed on the inputs can include
|
|
20
|
+
# it — the counterpart of GemServer::GemRepacker::FORMAT_VERSION.
|
|
21
|
+
# 2: a file that replaces a shipped one keeps that file's mode.
|
|
22
|
+
#
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
FORMAT_VERSION = 2
|
|
25
|
+
|
|
18
26
|
# Binary assets and sourcemaps are copied through untouched.
|
|
19
27
|
#
|
|
20
28
|
# @return [Array<String>]
|
|
@@ -203,7 +211,8 @@ class Paquette::NpmRepacker
|
|
|
203
211
|
end
|
|
204
212
|
|
|
205
213
|
# Injected files inherit the package's oldest mtime, keeping the tarball
|
|
206
|
-
# independent of when they were added.
|
|
214
|
+
# independent of when they were added. One that replaces a shipped file
|
|
215
|
+
# keeps that file's mode, so replacing a bin script leaves it executable.
|
|
207
216
|
#
|
|
208
217
|
# @param entries [Array<Paquette::Tarball::Entry>]
|
|
209
218
|
# @param root [String]
|
|
@@ -216,7 +225,8 @@ class Paquette::NpmRepacker
|
|
|
216
225
|
|
|
217
226
|
@files.each do |relative_path, content|
|
|
218
227
|
name = "#{root}/#{relative_path}"
|
|
219
|
-
by_name[name]
|
|
228
|
+
mode = by_name[name]&.mode || 0o644
|
|
229
|
+
by_name[name] = Paquette::Tarball::Entry.new(name: name, mode: mode, mtime: mtime, content: content.to_s)
|
|
220
230
|
end
|
|
221
231
|
|
|
222
232
|
by_name.values
|
|
@@ -192,11 +192,14 @@ class Paquette::NpmServer::Personalizer < SimpleDelegator
|
|
|
192
192
|
File.join(@cache_dir, "#{File.basename(package_name)}-#{version}-#{digest}.plain")
|
|
193
193
|
end
|
|
194
194
|
|
|
195
|
-
#
|
|
195
|
+
# Everything this personalizer would write into a tarball, and the
|
|
196
|
+
# repacker format it writes it with. Stable across processes, so a restart
|
|
197
|
+
# does not orphan the cache; a new repacker format does, on purpose.
|
|
196
198
|
#
|
|
197
199
|
# @return [String]
|
|
198
200
|
def personalization_digest
|
|
199
201
|
@personalization_digest ||= Digest::SHA256.hexdigest([
|
|
202
|
+
Paquette::NpmRepacker::FORMAT_VERSION,
|
|
200
203
|
@license_key,
|
|
201
204
|
@magic_comment_replacements.sort.inspect,
|
|
202
205
|
@files.sort.inspect,
|
data/lib/paquette/version.rb
CHANGED
data/rbi/paquette.rbi
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
module Paquette
|
|
6
6
|
DEFAULT_REGEXP_TIMEOUT = T.let(0.05, T.untyped)
|
|
7
7
|
MAX_PUSH_SIZE_BYTES = T.let(50 * 1024 * 1024, T.untyped)
|
|
8
|
-
VERSION = T.let("0.3.
|
|
8
|
+
VERSION = T.let("0.3.1", T.untyped)
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
11
|
# The regexp timeout every Rack app in this gem installs for the duration
|
|
@@ -1078,6 +1078,9 @@ module Paquette
|
|
|
1078
1078
|
# Rewrites the contents of a .gem into a new, byte-reproducible .gem —
|
|
1079
1079
|
# replacing magic comment lines, merging gemspec metadata and injecting files.
|
|
1080
1080
|
class GemRepacker
|
|
1081
|
+
FORMAT_VERSION = T.let(2, T.untyped)
|
|
1082
|
+
INJECTED_FILE_MODE = T.let(0o100644, T.untyped)
|
|
1083
|
+
|
|
1081
1084
|
# One-shot convenience for {#initialize} + {#repack}.
|
|
1082
1085
|
#
|
|
1083
1086
|
# _@param_ `gem_path`
|
|
@@ -1138,6 +1141,19 @@ module Paquette
|
|
|
1138
1141
|
sig { void }
|
|
1139
1142
|
def process_ruby_files; end
|
|
1140
1143
|
|
|
1144
|
+
# The mode of every entry in the source gem's data.tar.gz, as its packer
|
|
1145
|
+
# wrote it. The repack writes these back rather than what is on disk:
|
|
1146
|
+
# extraction masks each mode with this process's umask, and the .rb files
|
|
1147
|
+
# are rewritten through a Tempfile created 0600, so the disk says more
|
|
1148
|
+
# about the server than about the gem.
|
|
1149
|
+
#
|
|
1150
|
+
# _@return_ — entry name => mode
|
|
1151
|
+
sig { returns(T::Hash[String, Integer]) }
|
|
1152
|
+
def source_entry_modes; end
|
|
1153
|
+
|
|
1154
|
+
# A file that replaces one the gem shipped keeps that file's mode. Removed
|
|
1155
|
+
# before it is written, because the one it replaces may have been shipped
|
|
1156
|
+
# read-only.
|
|
1141
1157
|
sig { void }
|
|
1142
1158
|
def inject_files; end
|
|
1143
1159
|
|
|
@@ -1190,7 +1206,8 @@ module Paquette
|
|
|
1190
1206
|
end
|
|
1191
1207
|
|
|
1192
1208
|
# Gem::Package that reads the files it packs from a directory of the
|
|
1193
|
-
# caller's choosing instead of from the working directory, silently
|
|
1209
|
+
# caller's choosing instead of from the working directory, silently, and
|
|
1210
|
+
# records the modes it is given instead of the ones on disk.
|
|
1194
1211
|
# `gem build` only works because the CLI chdir's into the unpacked gem
|
|
1195
1212
|
# first — and chdir is process-global, so a server cannot do that. Only the
|
|
1196
1213
|
# two methods that reach outside are overridden; the .gem format itself
|
|
@@ -1209,19 +1226,23 @@ module Paquette
|
|
|
1209
1226
|
#
|
|
1210
1227
|
# _@param_ `build_time` — the timestamp stamped into the archive — see BuildTime for why this is a block around the build, not an argument
|
|
1211
1228
|
#
|
|
1229
|
+
# _@param_ `modes` — the tar mode of every file in spec.files
|
|
1230
|
+
#
|
|
1212
1231
|
# _@return_ — file_name
|
|
1213
1232
|
sig do
|
|
1214
1233
|
params(
|
|
1215
1234
|
spec: ::Gem::Specification,
|
|
1216
1235
|
root: String,
|
|
1217
1236
|
file_name: String,
|
|
1218
|
-
build_time: Time
|
|
1237
|
+
build_time: Time,
|
|
1238
|
+
modes: T::Hash[String, Integer]
|
|
1219
1239
|
).returns(String)
|
|
1220
1240
|
end
|
|
1221
|
-
def self.build(spec, root, file_name, build_time:); end
|
|
1241
|
+
def self.build(spec, root, file_name, build_time:, modes:); end
|
|
1222
1242
|
|
|
1223
|
-
# The RubyGems original with @root joined onto every path it looks at
|
|
1224
|
-
#
|
|
1243
|
+
# The RubyGems original with @root joined onto every path it looks at, and
|
|
1244
|
+
# each mode taken from @modes rather than from the file's stat. The names
|
|
1245
|
+
# written into the tar stay relative, which is what a .gem holds.
|
|
1225
1246
|
#
|
|
1226
1247
|
# _@param_ `tar`
|
|
1227
1248
|
sig { params(tar: ::Gem::Package::TarWriter).void }
|
|
@@ -1234,6 +1255,9 @@ module Paquette
|
|
|
1234
1255
|
|
|
1235
1256
|
sig { returns(String) }
|
|
1236
1257
|
attr_accessor :root
|
|
1258
|
+
|
|
1259
|
+
sig { returns(T::Hash[String, Integer]) }
|
|
1260
|
+
attr_accessor :modes
|
|
1237
1261
|
end
|
|
1238
1262
|
end
|
|
1239
1263
|
|
|
@@ -1400,8 +1424,10 @@ module Paquette
|
|
|
1400
1424
|
sig { params(gem_name: String, version: String, stat: ::File::Stat).returns(String) }
|
|
1401
1425
|
def plain_marker_path(gem_name, version, stat); end
|
|
1402
1426
|
|
|
1403
|
-
# Everything this personalizer would write into a gem,
|
|
1404
|
-
#
|
|
1427
|
+
# Everything this personalizer would write into a gem, and the repacker
|
|
1428
|
+
# format it writes it with, as one short hash. Stable across processes, so
|
|
1429
|
+
# a restart does not orphan the cache; a new repacker format does, on
|
|
1430
|
+
# purpose.
|
|
1405
1431
|
sig { returns(String) }
|
|
1406
1432
|
def personalization_digest; end
|
|
1407
1433
|
end
|
|
@@ -2766,7 +2792,9 @@ module Paquette
|
|
|
2766
2792
|
sig { params(package_name: String, version: String, stat: ::File::Stat).returns(String) }
|
|
2767
2793
|
def plain_marker_path(package_name, version, stat); end
|
|
2768
2794
|
|
|
2769
|
-
#
|
|
2795
|
+
# Everything this personalizer would write into a tarball, and the
|
|
2796
|
+
# repacker format it writes it with. Stable across processes, so a restart
|
|
2797
|
+
# does not orphan the cache; a new repacker format does, on purpose.
|
|
2770
2798
|
sig { returns(String) }
|
|
2771
2799
|
def personalization_digest; end
|
|
2772
2800
|
end
|
|
@@ -3252,6 +3280,7 @@ module Paquette
|
|
|
3252
3280
|
# GemServer::GemRepacker, with `package_json_extras:` for `gemspec_extras`.
|
|
3253
3281
|
# Byte-reproducible — see Paquette::Tarball for why that is load-bearing.
|
|
3254
3282
|
class NpmRepacker
|
|
3283
|
+
FORMAT_VERSION = T.let(2, T.untyped)
|
|
3255
3284
|
SOURCE_EXTENSIONS = T.let(%w[.js .mjs .cjs .jsx .ts .tsx .mts .cts].freeze, T.untyped)
|
|
3256
3285
|
|
|
3257
3286
|
# One line in, one line out — the rule the whole personalization scheme
|
|
@@ -3353,7 +3382,8 @@ module Paquette
|
|
|
3353
3382
|
def apply_package_json_extras(entries, root); end
|
|
3354
3383
|
|
|
3355
3384
|
# Injected files inherit the package's oldest mtime, keeping the tarball
|
|
3356
|
-
# independent of when they were added.
|
|
3385
|
+
# independent of when they were added. One that replaces a shipped file
|
|
3386
|
+
# keeps that file's mode, so replacing a bin script leaves it executable.
|
|
3357
3387
|
#
|
|
3358
3388
|
# _@param_ `entries`
|
|
3359
3389
|
#
|
data/sig/paquette.rbs
CHANGED
|
@@ -938,6 +938,9 @@ module Paquette
|
|
|
938
938
|
# Rewrites the contents of a .gem into a new, byte-reproducible .gem —
|
|
939
939
|
# replacing magic comment lines, merging gemspec metadata and injecting files.
|
|
940
940
|
class GemRepacker
|
|
941
|
+
FORMAT_VERSION: Integer
|
|
942
|
+
INJECTED_FILE_MODE: Integer
|
|
943
|
+
|
|
941
944
|
# One-shot convenience for {#initialize} + {#repack}.
|
|
942
945
|
#
|
|
943
946
|
# _@param_ `gem_path`
|
|
@@ -988,6 +991,18 @@ module Paquette
|
|
|
988
991
|
|
|
989
992
|
def process_ruby_files: () -> void
|
|
990
993
|
|
|
994
|
+
# The mode of every entry in the source gem's data.tar.gz, as its packer
|
|
995
|
+
# wrote it. The repack writes these back rather than what is on disk:
|
|
996
|
+
# extraction masks each mode with this process's umask, and the .rb files
|
|
997
|
+
# are rewritten through a Tempfile created 0600, so the disk says more
|
|
998
|
+
# about the server than about the gem.
|
|
999
|
+
#
|
|
1000
|
+
# _@return_ — entry name => mode
|
|
1001
|
+
def source_entry_modes: () -> ::Hash[String, Integer]
|
|
1002
|
+
|
|
1003
|
+
# A file that replaces one the gem shipped keeps that file's mode. Removed
|
|
1004
|
+
# before it is written, because the one it replaces may have been shipped
|
|
1005
|
+
# read-only.
|
|
991
1006
|
def inject_files: () -> void
|
|
992
1007
|
|
|
993
1008
|
# _@param_ `file_path`
|
|
@@ -1032,7 +1047,8 @@ module Paquette
|
|
|
1032
1047
|
end
|
|
1033
1048
|
|
|
1034
1049
|
# Gem::Package that reads the files it packs from a directory of the
|
|
1035
|
-
# caller's choosing instead of from the working directory, silently
|
|
1050
|
+
# caller's choosing instead of from the working directory, silently, and
|
|
1051
|
+
# records the modes it is given instead of the ones on disk.
|
|
1036
1052
|
# `gem build` only works because the CLI chdir's into the unpacked gem
|
|
1037
1053
|
# first — and chdir is process-global, so a server cannot do that. Only the
|
|
1038
1054
|
# two methods that reach outside are overridden; the .gem format itself
|
|
@@ -1051,16 +1067,20 @@ module Paquette
|
|
|
1051
1067
|
#
|
|
1052
1068
|
# _@param_ `build_time` — the timestamp stamped into the archive — see BuildTime for why this is a block around the build, not an argument
|
|
1053
1069
|
#
|
|
1070
|
+
# _@param_ `modes` — the tar mode of every file in spec.files
|
|
1071
|
+
#
|
|
1054
1072
|
# _@return_ — file_name
|
|
1055
1073
|
def self.build: (
|
|
1056
1074
|
::Gem::Specification spec,
|
|
1057
1075
|
String root,
|
|
1058
1076
|
String file_name,
|
|
1059
|
-
build_time: Time
|
|
1077
|
+
build_time: Time,
|
|
1078
|
+
modes: ::Hash[String, Integer]
|
|
1060
1079
|
) -> String
|
|
1061
1080
|
|
|
1062
|
-
# The RubyGems original with @root joined onto every path it looks at
|
|
1063
|
-
#
|
|
1081
|
+
# The RubyGems original with @root joined onto every path it looks at, and
|
|
1082
|
+
# each mode taken from @modes rather than from the file's stat. The names
|
|
1083
|
+
# written into the tar stay relative, which is what a .gem holds.
|
|
1064
1084
|
#
|
|
1065
1085
|
# _@param_ `tar`
|
|
1066
1086
|
def add_files: (::Gem::Package::TarWriter tar) -> void
|
|
@@ -1070,6 +1090,8 @@ module Paquette
|
|
|
1070
1090
|
def say: () -> void
|
|
1071
1091
|
|
|
1072
1092
|
attr_accessor root: String
|
|
1093
|
+
|
|
1094
|
+
attr_accessor modes: ::Hash[String, Integer]
|
|
1073
1095
|
end
|
|
1074
1096
|
end
|
|
1075
1097
|
|
|
@@ -1219,8 +1241,10 @@ module Paquette
|
|
|
1219
1241
|
# _@param_ `stat`
|
|
1220
1242
|
def plain_marker_path: (String gem_name, String version, ::File::Stat stat) -> String
|
|
1221
1243
|
|
|
1222
|
-
# Everything this personalizer would write into a gem,
|
|
1223
|
-
#
|
|
1244
|
+
# Everything this personalizer would write into a gem, and the repacker
|
|
1245
|
+
# format it writes it with, as one short hash. Stable across processes, so
|
|
1246
|
+
# a restart does not orphan the cache; a new repacker format does, on
|
|
1247
|
+
# purpose.
|
|
1224
1248
|
def personalization_digest: () -> String
|
|
1225
1249
|
end
|
|
1226
1250
|
|
|
@@ -2411,7 +2435,9 @@ module Paquette
|
|
|
2411
2435
|
# _@param_ `stat`
|
|
2412
2436
|
def plain_marker_path: (String package_name, String version, ::File::Stat stat) -> String
|
|
2413
2437
|
|
|
2414
|
-
#
|
|
2438
|
+
# Everything this personalizer would write into a tarball, and the
|
|
2439
|
+
# repacker format it writes it with. Stable across processes, so a restart
|
|
2440
|
+
# does not orphan the cache; a new repacker format does, on purpose.
|
|
2415
2441
|
def personalization_digest: () -> String
|
|
2416
2442
|
end
|
|
2417
2443
|
|
|
@@ -2820,6 +2846,7 @@ module Paquette
|
|
|
2820
2846
|
# GemServer::GemRepacker, with `package_json_extras:` for `gemspec_extras`.
|
|
2821
2847
|
# Byte-reproducible — see Paquette::Tarball for why that is load-bearing.
|
|
2822
2848
|
class NpmRepacker
|
|
2849
|
+
FORMAT_VERSION: Integer
|
|
2823
2850
|
SOURCE_EXTENSIONS: ::Array[String]
|
|
2824
2851
|
|
|
2825
2852
|
# One line in, one line out — the rule the whole personalization scheme
|
|
@@ -2906,7 +2933,8 @@ module Paquette
|
|
|
2906
2933
|
def apply_package_json_extras: (::Array[Paquette::Tarball::Entry] entries, String root) -> ::Array[Paquette::Tarball::Entry]
|
|
2907
2934
|
|
|
2908
2935
|
# Injected files inherit the package's oldest mtime, keeping the tarball
|
|
2909
|
-
# independent of when they were added.
|
|
2936
|
+
# independent of when they were added. One that replaces a shipped file
|
|
2937
|
+
# keeps that file's mode, so replacing a bin script leaves it executable.
|
|
2910
2938
|
#
|
|
2911
2939
|
# _@param_ `entries`
|
|
2912
2940
|
#
|
|
@@ -2,6 +2,7 @@ require_relative "../test_helper"
|
|
|
2
2
|
require "digest"
|
|
3
3
|
require "rubygems/package"
|
|
4
4
|
require "open3"
|
|
5
|
+
require "zlib"
|
|
5
6
|
|
|
6
7
|
class GemRepackerTest < Minitest::Test
|
|
7
8
|
def setup
|
|
@@ -29,6 +30,59 @@ class GemRepackerTest < Minitest::Test
|
|
|
29
30
|
File.delete(new_gem_path) if File.exist?(new_gem_path)
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# Every entry keeps the mode the source gem's packer gave it — not the 0600
|
|
34
|
+
# of the Tempfile the .rb files are rewritten through (a gem that installs
|
|
35
|
+
# root-owned 0600 files cannot be required by anyone else), and not the
|
|
36
|
+
# mode extraction leaves on disk after this process's umask.
|
|
37
|
+
def test_repack_keeps_the_file_modes_of_the_source_gem
|
|
38
|
+
Dir.mktmpdir("gem_repacker_permissions") do |dir|
|
|
39
|
+
source = build_gem_with_modes(dir)
|
|
40
|
+
|
|
41
|
+
[0o022, 0o077].each do |umask|
|
|
42
|
+
repacked = with_umask(umask) do
|
|
43
|
+
Paquette::GemServer::GemRepacker.repack(source,
|
|
44
|
+
magic_comment_replacements: {"# paquette_license_info" => "LIC-1"},
|
|
45
|
+
into: File.join(dir, "repacked-#{umask.to_s(8)}.gem"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
assert_equal data_entry_modes(source), data_entry_modes(repacked), "under umask #{umask.to_s(8)}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_repack_is_byte_identical_whatever_the_umask
|
|
54
|
+
Dir.mktmpdir("gem_repacker_umask") do |dir|
|
|
55
|
+
source = build_gem_with_modes(dir)
|
|
56
|
+
|
|
57
|
+
digests = [0o022, 0o002, 0o077].map do |umask|
|
|
58
|
+
with_umask(umask) do
|
|
59
|
+
repacked = Paquette::GemServer::GemRepacker.repack(source,
|
|
60
|
+
files: {"LICENSE-EXTRA.txt" => "Licensed to somebody\n"},
|
|
61
|
+
into: File.join(dir, "repacked-#{umask.to_s(8)}.gem"))
|
|
62
|
+
Digest::SHA256.file(repacked).hexdigest
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
assert_equal 1, digests.uniq.length
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_repack_gives_an_injected_file_a_readable_mode_and_a_replaced_one_its_old_mode
|
|
71
|
+
Dir.mktmpdir("gem_repacker_injected") do |dir|
|
|
72
|
+
source = build_gem_with_modes(dir)
|
|
73
|
+
|
|
74
|
+
repacked = with_umask(0o077) do
|
|
75
|
+
Paquette::GemServer::GemRepacker.repack(source,
|
|
76
|
+
files: {"LICENSE-EXTRA.txt" => "new\n", "data/readonly.txt" => "replaced\n"},
|
|
77
|
+
into: File.join(dir, "repacked.gem"))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
modes = data_entry_modes(repacked)
|
|
81
|
+
assert_equal 0o100644, modes["LICENSE-EXTRA.txt"]
|
|
82
|
+
assert_equal data_entry_modes(source)["data/readonly.txt"], modes["data/readonly.txt"]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
32
86
|
def test_repack_with_metadata_keys
|
|
33
87
|
assert File.exist?(@test_gem_path), "Test gem not found at #{@test_gem_path}"
|
|
34
88
|
|
|
@@ -326,6 +380,59 @@ class GemRepackerTest < Minitest::Test
|
|
|
326
380
|
|
|
327
381
|
private
|
|
328
382
|
|
|
383
|
+
# A gem whose files do not all share one mode, the way a gem built on a
|
|
384
|
+
# umask-002 machine or shipping an executable arrives.
|
|
385
|
+
#
|
|
386
|
+
# @return [String] path of the built gem
|
|
387
|
+
def build_gem_with_modes(dir)
|
|
388
|
+
root = File.join(dir, "source")
|
|
389
|
+
{
|
|
390
|
+
"lib/moded.rb" => ["# paquette_license_info\nmodule Moded; end\n", 0o664],
|
|
391
|
+
"exe/moded" => ["#!/usr/bin/env ruby\n", 0o755],
|
|
392
|
+
"data/readonly.txt" => ["read me\n", 0o444],
|
|
393
|
+
"README.md" => ["# Moded\n", 0o644]
|
|
394
|
+
}.each do |path, (content, mode)|
|
|
395
|
+
full = File.join(root, path)
|
|
396
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
397
|
+
File.write(full, content)
|
|
398
|
+
File.chmod(mode, full)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
spec = Gem::Specification.new do |s|
|
|
402
|
+
s.name = "moded"
|
|
403
|
+
s.version = "0.1.0"
|
|
404
|
+
s.summary = "Files with different modes"
|
|
405
|
+
s.authors = ["Paquette"]
|
|
406
|
+
s.files = ["README.md", "data/readonly.txt", "exe/moded", "lib/moded.rb"]
|
|
407
|
+
end
|
|
408
|
+
modes = spec.files.to_h { |path| [path, File.stat(File.join(root, path)).mode] }
|
|
409
|
+
|
|
410
|
+
gem_path = File.join(dir, "moded-0.1.0.gem")
|
|
411
|
+
Paquette::GemServer::GemRepacker::RootedPackage.build(spec, root, gem_path, build_time: Time.utc(2026, 1, 1), modes: modes)
|
|
412
|
+
gem_path
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def with_umask(umask)
|
|
416
|
+
previous = File.umask(umask)
|
|
417
|
+
yield
|
|
418
|
+
ensure
|
|
419
|
+
File.umask(previous)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# @return [Hash{String => Integer}] tar entry name => mode, from data.tar.gz
|
|
423
|
+
def data_entry_modes(gem_path)
|
|
424
|
+
modes = {}
|
|
425
|
+
File.open(gem_path, "rb") do |io|
|
|
426
|
+
Gem::Package::TarReader.new(io).each do |entry|
|
|
427
|
+
next unless entry.full_name == "data.tar.gz"
|
|
428
|
+
Zlib::GzipReader.wrap(entry) do |gz|
|
|
429
|
+
Gem::Package::TarReader.new(gz).each { |file| modes[file.full_name] = file.header.mode }
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
modes
|
|
434
|
+
end
|
|
435
|
+
|
|
329
436
|
def verify_repacking(gem_path, expected_random_chars)
|
|
330
437
|
# Create a temporary directory to unpack the new gem
|
|
331
438
|
temp_dir = Dir.mktmpdir("verify_repacking")
|
data/test/npm_repacker_test.rb
CHANGED
|
@@ -187,6 +187,46 @@ class NpmRepackerTest < Minitest::Test
|
|
|
187
187
|
assert_equal 1, Paquette::Tarball.entries(path).count { |entry| entry.name == "package/README.md" }
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
+
def test_an_injected_file_keeps_the_mode_of_the_one_it_replaces
|
|
191
|
+
source = File.join(@work_dir, "source.tgz")
|
|
192
|
+
Paquette::Tarball.write(source, [
|
|
193
|
+
Paquette::Tarball::Entry.new(name: "package/package.json", mode: 0o644, mtime: NPM_EPOCH, content: '{"name":"widget"}'),
|
|
194
|
+
Paquette::Tarball::Entry.new(name: "package/bin/cli.js", mode: 0o755, mtime: NPM_EPOCH, content: "#!/usr/bin/env node\n")
|
|
195
|
+
])
|
|
196
|
+
|
|
197
|
+
path = Paquette::NpmRepacker.repack(source,
|
|
198
|
+
files: {"bin/cli.js" => "#!/usr/bin/env node\n// licensed\n", "LICENSE.txt" => "Licensed to Acme"}, into: into)
|
|
199
|
+
|
|
200
|
+
modes = Paquette::Tarball.entries(path).to_h { |entry| [entry.name, entry.mode] }
|
|
201
|
+
assert_equal 0o755, modes["package/bin/cli.js"]
|
|
202
|
+
assert_equal 0o644, modes["package/LICENSE.txt"]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The tarball is rewritten in memory and never unpacked, so nothing on the
|
|
206
|
+
# way — a Tempfile, the process umask — reaches the modes it writes. The
|
|
207
|
+
# gem repacker learned that the hard way.
|
|
208
|
+
def test_repacking_is_byte_identical_whatever_the_umask
|
|
209
|
+
source = File.join(@work_dir, "source.tgz")
|
|
210
|
+
Paquette::Tarball.write(source, [
|
|
211
|
+
Paquette::Tarball::Entry.new(name: "package/package.json", mode: 0o664, mtime: NPM_EPOCH, content: '{"name":"widget"}'),
|
|
212
|
+
Paquette::Tarball::Entry.new(name: "package/index.js", mode: 0o600, mtime: NPM_EPOCH, content: "module.exports = 1;\n"),
|
|
213
|
+
Paquette::Tarball::Entry.new(name: "package/bin/cli.js", mode: 0o755, mtime: NPM_EPOCH, content: "#!/usr/bin/env node\n")
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
digests = [0o022, 0o002, 0o077].map do |umask|
|
|
217
|
+
previous = File.umask(umask)
|
|
218
|
+
begin
|
|
219
|
+
path = Paquette::NpmRepacker.repack(source,
|
|
220
|
+
files: {"LICENSE.txt" => "Licensed to Acme"}, into: into("umask-#{umask.to_s(8)}.tgz"))
|
|
221
|
+
Digest::SHA256.file(path).hexdigest
|
|
222
|
+
ensure
|
|
223
|
+
File.umask(previous)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
assert_equal 1, digests.uniq.length
|
|
228
|
+
end
|
|
229
|
+
|
|
190
230
|
def test_repack_changes_the_checksum
|
|
191
231
|
source = fixture_package
|
|
192
232
|
original = Digest::SHA256.file(source).hexdigest
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: paquette
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Julik Tarkhanov
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-30 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rack
|