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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2e249bb3b01d7ddc7e3b78968579b004ea9ff8862895f51cdd40f814e5ab80f
4
- data.tar.gz: f7c8e608ad032792fdecfaf2b66e5f0ac975932ce028b3cfdf5066c758401799
3
+ metadata.gz: cad055b11059ab61aa4f330936f3f6eb8bf43256599c7cfbe836f52e5cd00ac5
4
+ data.tar.gz: 1571bb0d09b5c88d917ec64b16ae1161f317f372b81f0b76b24b7be3fc01fcdc
5
5
  SHA512:
6
- metadata.gz: de59c104c34db56212e2c01b0f6feaab8b96f1ec8040c252784ffd4efd47c1bdba89307634617bd574a2ad4cb0558cf36ba8d54f12e75acc559e6b06ae137a07
7
- data.tar.gz: ddd5a0a2353161eb730ca6ca99f91eac33e1327fbe200e14965ec0d46768845a12305442634b5b6eddd926985ab5f29d119fa1b54b094ab2afc61a07f58d7d23
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
- # The RubyGems original with @root joined onto every path it looks at. The
36
- # names written into the tar stay relative, which is what a .gem holds.
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), stat.mode
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, stat.mode, stat.size do |dst_io|
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, as one short hash.
291
- # Stable across processes, so a restart does not orphan the cache.
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] = Paquette::Tarball::Entry.new(name: name, mode: 0o644, mtime: mtime, content: content.to_s)
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
- # Stable across processes, so a restart does not orphan the cache.
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,
@@ -1,4 +1,4 @@
1
1
  module Paquette
2
2
  # @return [String]
3
- VERSION = "0.3.0"
3
+ VERSION = "0.3.1"
4
4
  end
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.0", T.untyped)
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. The
1224
- # names written into the tar stay relative, which is what a .gem holds.
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, as one short hash.
1404
- # Stable across processes, so a restart does not orphan the cache.
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
- # Stable across processes, so a restart does not orphan the cache.
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. The
1063
- # names written into the tar stay relative, which is what a .gem holds.
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, as one short hash.
1223
- # Stable across processes, so a restart does not orphan the cache.
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
- # Stable across processes, so a restart does not orphan the cache.
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")
@@ -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.0
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-27 00:00:00.000000000 Z
10
+ date: 2026-09-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rack