paquette 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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +10 -0
  3. data/README.md +90 -0
  4. data/lib/paquette/gem_server/directory_gem_repository.rb +154 -0
  5. data/lib/paquette/gem_server/gem_repacker.rb +170 -0
  6. data/lib/paquette/gem_server/gem_repository.rb +53 -0
  7. data/lib/paquette/gem_server/personalizer.rb +66 -0
  8. data/lib/paquette/gem_server/read_gated_repository.rb +74 -0
  9. data/lib/paquette/gem_server.rb +393 -0
  10. data/lib/paquette/npm_repacker.rb +153 -0
  11. data/lib/paquette/npm_server/directory_npm_repository.rb +127 -0
  12. data/lib/paquette/npm_server/gated_npm_repository.rb +66 -0
  13. data/lib/paquette/npm_server/npm_repository.rb +53 -0
  14. data/lib/paquette/npm_server.rb +98 -0
  15. data/lib/paquette/routes.rb +78 -0
  16. data/lib/paquette/subdomain_router.rb +59 -0
  17. data/lib/paquette/version.rb +3 -0
  18. data/lib/paquette.rb +12 -0
  19. data/test/fixtures/gems/minuscule_test/minuscule_test-0.1.0.gem +0 -0
  20. data/test/fixtures/gems/zip_kit/zip_kit-6.2.0.gem +0 -0
  21. data/test/fixtures/gems/zip_kit/zip_kit-6.2.1.gem +0 -0
  22. data/test/fixtures/npm/react-dropzone/react-dropzone-14.3.8.tgz +0 -0
  23. data/test/gem_server/bundler_install_test.rb +86 -0
  24. data/test/gem_server/directory_gem_repository_test.rb +208 -0
  25. data/test/gem_server/gem_repacker_test.rb +289 -0
  26. data/test/gem_server/personalizer_metadata_test.rb +49 -0
  27. data/test/gem_server/personalizer_test.rb +66 -0
  28. data/test/gem_server/read_gated_repository_test.rb +254 -0
  29. data/test/gem_server/repository_stack_test.rb +71 -0
  30. data/test/gem_server_test.rb +339 -0
  31. data/test/npm_repacker_sourcemap_test.rb +108 -0
  32. data/test/npm_repacker_test.rb +117 -0
  33. data/test/npm_server_test.rb +140 -0
  34. data/test/routes_test.rb +142 -0
  35. data/test/subdomain_router_integration_test.rb +126 -0
  36. data/test/subdomain_router_test.rb +125 -0
  37. data/test/test_helper.rb +12 -0
  38. metadata +117 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4d8ed39d45d99f0d96e971b61bd47529362b0efa35f9b2704134628b2001bfa3
4
+ data.tar.gz: 765118b95e910e0cac4b34b8ddf841471237ef93c87fc5f59a5b62e45b470a04
5
+ SHA512:
6
+ metadata.gz: fbd2e25eecb61a6c5e6cc2dba5bce5283a434fd65bfd65ef175f813c3d683342b42ed3d3f7f634943ea4b818df4f096a160a8d0a2dda3923e37e5b0f9d57a1c0
7
+ data.tar.gz: 6e90af21e71e67d05f2ff3f89a26cbb65913a1ce196e62cc03d31e1d003a11411c5d0ce8feb9ebb97c64fc72a0311315ee41f83a2f32c22224a7500e116e5484
data/LICENSE.md ADDED
@@ -0,0 +1,10 @@
1
+ # O'Saasy License Agreement
2
+
3
+ Copyright © 2026 and onwards, Stanquette
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+ 2. No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # Paquette
2
+
3
+ Paquette is a (sligtly unhinged) Rack-based server for libraries. At the moment it serves gems and NPM packages. It is very basic and is made to serve packages gated by a licensing mechanism, which is supposed to be BYO.
4
+
5
+ Things are very in flux at the moment, but it may come in handy.
6
+
7
+ ## Setup
8
+
9
+ 1. Install dependencies:
10
+ ```bash
11
+ bundle install
12
+ ```
13
+
14
+ 2. Start the server:
15
+ ```bash
16
+ bundle exec puma
17
+ ```
18
+
19
+ ## Repository gating
20
+
21
+ Paquette is built on the premise that you can have a corpus of libraries you offer, and deduce - from the `Authorization` HTTP header or by other means - which packages a user may download. Only the packages they have access to get included in the API responses - version lists, checksum lists and so on.
22
+
23
+ `Paquette::GemServer` itself is dumb: it takes one repository object and routes every read, push, and yank through it. You build the stack per request by nesting plain constructors — no DSL, no callbacks registered on the server, just wrappers wrapping wrappers. For example, inside a Rack endpoint:
24
+
25
+ ```ruby
26
+ def call(env)
27
+ username = env["REMOTE_USER"]
28
+ user = User.where(login: username).first!
29
+ packages_dir = Rails.root.join("packages", "gems").to_s
30
+
31
+ repo = Paquette::GemServer::DirectoryGemRepository.new(packages_dir)
32
+
33
+ repo_with_gating = Paquette::GemServer::ReadGatedRepository.new(repo) do |name:, version: nil|
34
+ user.license.gem_names.include?(name)
35
+ end
36
+
37
+ repo_with_gating_and_personalization = Paquette::GemServer::Personalizer.new(
38
+ repo_with_gating,
39
+ license_key: user.license_key,
40
+ magic_comment_replacements: {"# paquette_license_info" => user.license_key}
41
+ )
42
+
43
+ Paquette::GemServer.new(repo_with_gating_and_personalization).call(env)
44
+ end
45
+ ```
46
+
47
+ Each line adds one capability:
48
+
49
+ - `DirectoryGemRepository` reads `.gem` files from disk and accepts pushes and yanks.
50
+ - `ReadGatedRepository` wraps a repository and filters every name/version through the block — unauthorized gems simply stop existing as far as the server is concerned. It also refuses writes outright: if you gate reads, you are saying this caller is not the right party to mutate the corpus, so `add_gem`/`yank_gem` raise `WriteNotAllowed` (which the server turns into a 403).
51
+ - `Personalizer` wraps a repository and rewrites each served `.gem` on the fly to embed the user's license key.
52
+
53
+ Because the wrappers are plain Ruby objects composed at the call site, per-user state (the `user` variable) is captured by ordinary closures. Drop a wrapper to disable that layer; add another by slotting in one more constructor. Whether the server will accept pushes and yanks is decided by what you build — wrap the base repo in `ReadGatedRepository` and writes are blocked; hand the server a bare `DirectoryGemRepository` (or your own wrapper that permits writes) and they go through.
54
+
55
+ The server will run on `http://localhost:9292` by default. Note that the NPM registry and the Rubygems registry have to live on separate domains - so they will respond on whichever domain is passed in that has `gems.` or `npm.` as first subdomain. If your OS supports `.localhost` TLDs, you can access `gems.whatever.localhost:9292` and it will respond.
56
+
57
+ ## Usage for gems
58
+
59
+ Paquette contains a gem server. This is a separate Rack app which you can use without the NPM server, for example - inside of your Rails app. You can interact with it using `gem` commands, as if it were any other gem server, or by just placing stuff on the filesystem.
60
+
61
+ ### Publishing gems into Paquette
62
+
63
+ You can publish gems two ways.
64
+
65
+ 1. Place `.gem` files in the `gems/` directory. The filename should follow the format: `gemname-version.gem`
66
+ 2. Actually do a `gem push` - from your shell, do `gem push --host http://gems.localhost:9292 pkg/your-gem-0.1.0.gem`
67
+
68
+ Note that Paquette will use whichever auth you wrap it with - that is to say, in the default dev setup - _none._ I told you it is slightly unhinged.
69
+
70
+ ### Consuming gems provided by a Paquette server
71
+
72
+ To install gems from Paquette, set it as source in your Gemfile - and provide auth for whichever auth mechanism you wrap it with:
73
+
74
+ ```ruby
75
+ gem "private_algos", source: "https://tok_998218907784:x-oauth-basic@gems.paquette.acme.com"
76
+ ```
77
+
78
+ ### API Endpoints
79
+
80
+ - `GET /` - Repository info
81
+ - `GET /api/v1/dependencies` - Gem dependencies
82
+ - `GET /api/v1/versions` - Available gem versions
83
+ - `GET /api/v1/names` - Available gem names
84
+ - `GET /api/v1/search.json` - Search gems
85
+ - `GET /gems/{gemname-version.gem}` - Download gem file
86
+ - `POST /api/v1/gems` - Upload gem (basic implementation)
87
+
88
+ ## License
89
+
90
+ Paquette is offered under the terms of the [O'Sassy license](https://osaasy.dev/) - basically, don't make it into your own product. Use it to sell your libraries. And godspeed!
@@ -0,0 +1,154 @@
1
+ require_relative "gem_repository"
2
+ require "rubygems/package"
3
+ require "tempfile"
4
+
5
+ module Paquette
6
+ class GemServer
7
+ # Repository implementation that reads gems from a directory
8
+ class DirectoryGemRepository < GemRepository
9
+ class GemAlreadyExists < StandardError; end
10
+
11
+ class InvalidGem < StandardError; end
12
+
13
+ class GemNotFound < StandardError; end
14
+
15
+ class GemYanked < StandardError; end
16
+
17
+ def initialize(gems_dir)
18
+ @gems_dir = gems_dir
19
+ FileUtils.mkdir_p(@gems_dir)
20
+ end
21
+
22
+ # Persists a .gem file from its raw binary contents. Returns the parsed
23
+ # spec on success. Raises InvalidGem when the payload can't be opened as
24
+ # a gem, or GemAlreadyExists if the name+version is already on disk.
25
+ def add_gem(binary_data)
26
+ raise InvalidGem, "Empty gem payload" if binary_data.nil? || binary_data.empty?
27
+
28
+ tmp = Tempfile.new(["paquette_push", ".gem"])
29
+ tmp.binmode
30
+ tmp.write(binary_data)
31
+ tmp.close
32
+
33
+ spec = begin
34
+ Gem::Package.new(tmp.path).spec
35
+ rescue Gem::Package::Error, StandardError => e
36
+ raise InvalidGem, "Could not read gem: #{e.message}"
37
+ end
38
+
39
+ name = spec.name
40
+ version = spec.version.to_s
41
+ raise GemYanked, "#{name}-#{version} was yanked and cannot be republished" if tomb_exists?(name, version)
42
+ raise GemAlreadyExists, "#{name}-#{version} already exists" if gem_exists?(name, version)
43
+
44
+ dest_dir = File.join(@gems_dir, name)
45
+ FileUtils.mkdir_p(dest_dir)
46
+ FileUtils.mv(tmp.path, gem_file_path(name, version))
47
+
48
+ spec
49
+ ensure
50
+ tmp&.close unless tmp&.closed?
51
+ File.unlink(tmp.path) if tmp && File.exist?(tmp.path)
52
+ end
53
+
54
+ # Yanks a gem by renaming its .gem file to .gem.tomb. The tomb prevents
55
+ # the same name+version from being re-pushed later. Raises GemNotFound
56
+ # when the gem was never present or already yanked.
57
+ def yank_gem(gem_name, version)
58
+ gem_path = gem_file_path(gem_name, version)
59
+ raise GemNotFound, "#{gem_name}-#{version} not found" unless File.exist?(gem_path)
60
+
61
+ FileUtils.mv(gem_path, tomb_file_path(gem_name, version))
62
+ nil
63
+ end
64
+
65
+ def tomb_file_path(gem_name, version)
66
+ gem_file_path(gem_name, version) + ".tomb"
67
+ end
68
+
69
+ def tomb_exists?(gem_name, version)
70
+ File.exist?(tomb_file_path(gem_name, version))
71
+ end
72
+
73
+ def gem_names
74
+ Dir.glob(File.join(@gems_dir, "*")).select { |path| File.directory?(path) }.map do |package_path|
75
+ File.basename(package_path)
76
+ end.sort
77
+ end
78
+
79
+ def gem_versions
80
+ versions = []
81
+ gem_names.each do |gem_name|
82
+ versions_for_gem(gem_name).each do |version|
83
+ versions << [gem_name, version]
84
+ end
85
+ end
86
+ versions.sort
87
+ end
88
+
89
+ def versions_for_gem(gem_name)
90
+ gem_dir = File.join(@gems_dir, gem_name)
91
+ return [] unless Dir.exist?(gem_dir)
92
+
93
+ Dir.glob(File.join(gem_dir, "*.gem")).map do |gem_path|
94
+ filename = File.basename(gem_path, ".gem")
95
+ if (match = filename.match(/^#{Regexp.escape(gem_name)}-(\d+\.\d+\.\d+.*)$/))
96
+ match[1]
97
+ end
98
+ end.compact.sort
99
+ end
100
+
101
+ def gem_file_path(gem_name, version)
102
+ File.join(@gems_dir, gem_name, "#{gem_name}-#{version}.gem")
103
+ end
104
+
105
+ def gem_exists?(gem_name, version)
106
+ File.exist?(gem_file_path(gem_name, version))
107
+ end
108
+
109
+ def gem_spec(gem_name, version)
110
+ gem_file = gem_file_path(gem_name, version)
111
+ return nil unless File.exist?(gem_file)
112
+
113
+ pkg = Gem::Package.new(gem_file)
114
+ pkg.spec
115
+ end
116
+
117
+ def gem_dependencies(gem_name, version)
118
+ spec = gem_spec(gem_name, version)
119
+ return [] unless spec
120
+
121
+ # Only include runtime dependencies, not development dependencies
122
+ runtime_deps = spec.dependencies.select { |dep| dep.type == :runtime }
123
+ runtime_deps.map do |dep|
124
+ {
125
+ name: dep.name,
126
+ requirements: dep.requirement.to_s
127
+ }
128
+ end
129
+ end
130
+
131
+ def compact_info(gem_name)
132
+ versions = versions_for_gem(gem_name)
133
+ return [] if versions.empty?
134
+
135
+ require "digest"
136
+
137
+ versions.map do |version|
138
+ spec = gem_spec(gem_name, version)
139
+ next unless spec
140
+
141
+ # Calculate SHA256 checksum of the gem file
142
+ gem_file = gem_file_path(gem_name, version)
143
+ checksum = Digest::SHA256.file(gem_file).hexdigest
144
+
145
+ # Get required Ruby version from gemspec
146
+ ruby_version = spec.required_ruby_version&.to_s || ">= 0"
147
+
148
+ # Format: version |checksum:sha256_checksum,ruby:required_ruby_version
149
+ "#{version} |checksum:#{checksum},ruby:#{ruby_version}"
150
+ end.compact
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,170 @@
1
+ require "rubygems"
2
+ require "rubygems/package"
3
+ require "fileutils"
4
+ require "digest"
5
+ require "tmpdir"
6
+ require "tempfile"
7
+ require "open3"
8
+
9
+ module Paquette
10
+ class GemServer
11
+ class GemRepacker
12
+ def self.repack(gem_path, gemspec_extras: {}, magic_comment_replacements: {}, files: {}, &block)
13
+ new(gem_path, gemspec_extras: gemspec_extras, magic_comment_replacements: magic_comment_replacements, files: files).repack(&block)
14
+ end
15
+
16
+ def initialize(gem_path, gemspec_extras: {}, magic_comment_replacements: {}, files: {})
17
+ @gem_path = gem_path
18
+ @gemspec_extras = gemspec_extras
19
+ @magic_comment_replacements = magic_comment_replacements
20
+ @files = files
21
+ @temp_dir = nil
22
+ @unpacked_gem_dir = nil
23
+ end
24
+
25
+ def repack
26
+ raise ArgumentError, "Gem file not found: #{@gem_path}" unless File.exist?(@gem_path)
27
+
28
+ unpack_gem
29
+ process_ruby_files
30
+ inject_files
31
+ new_gem_path = repackage_gem
32
+ cleanup
33
+ new_gem_path
34
+ end
35
+
36
+ private
37
+
38
+ def unpack_gem
39
+ @temp_dir = Dir.mktmpdir("gem_repacker")
40
+ @unpacked_gem_dir = File.join(@temp_dir, "unpacked_gem")
41
+ FileUtils.mkdir_p(@unpacked_gem_dir)
42
+
43
+ # Use gem unpack command to extract the gem
44
+ _, stderr, status = Open3.capture3("gem unpack #{@gem_path} --target=#{@unpacked_gem_dir}")
45
+ unless status.success?
46
+ raise "Failed to unpack gem: #{@gem_path}. Error: #{stderr}"
47
+ end
48
+
49
+ # Find the unpacked gem directory - it should be the only directory in unpacked_gem_dir
50
+ @gem_dir = Dir.glob(File.join(@unpacked_gem_dir, "*")).find { |path| File.directory?(path) }
51
+ raise "Could not find unpacked gem directory" unless @gem_dir
52
+ end
53
+
54
+ def process_ruby_files
55
+ Dir.glob(File.join(@gem_dir, "**", "*.rb")).each do |rb_file|
56
+ process_ruby_file(rb_file)
57
+ end
58
+ end
59
+
60
+ def inject_files
61
+ @files.each do |file_path, content|
62
+ # Ensure the file path is relative to the gem directory
63
+ full_path = File.join(@gem_dir, file_path)
64
+
65
+ # Create parent directories if they don't exist
66
+ FileUtils.mkdir_p(File.dirname(full_path))
67
+
68
+ # Write the file content
69
+ File.write(full_path, content)
70
+ end
71
+ end
72
+
73
+ def process_ruby_file(file_path)
74
+ # Create a temporary file for output
75
+ temp_output = Tempfile.new("gem_repacker_output")
76
+
77
+ # Open input file in binary mode for reading
78
+ File.open(file_path, "rb") do |input_file|
79
+ # Set temp file to binary mode
80
+ temp_output.binmode
81
+
82
+ # Apply magic comment replacements if any are defined
83
+ if @magic_comment_replacements.any?
84
+ apply_magic_comment_replacements(input_file, temp_output)
85
+ else
86
+ # Just copy the file as-is if no magic comment replacements
87
+ IO.copy_stream(input_file, temp_output)
88
+ end
89
+
90
+ # Flush the temp file to ensure all data is written
91
+ temp_output.flush
92
+ end
93
+
94
+ # Replace the original file with the processed content
95
+ FileUtils.mv(temp_output.path, file_path)
96
+ ensure
97
+ temp_output.close
98
+ temp_output.unlink if File.exist?(temp_output.path)
99
+ end
100
+
101
+ def apply_magic_comment_replacements(input_file, output_file)
102
+ input_file.each_line do |line|
103
+ # Check if this line matches any of our magic comment replacements
104
+ replacement_found = false
105
+ @magic_comment_replacements.each do |magic_comment, replacement_value|
106
+ if line.chomp == magic_comment
107
+ output_file.puts("# #{replacement_value}\n")
108
+ IO.copy_stream(input_file, output_file) # Copy the rest
109
+ replacement_found = true
110
+ break
111
+ end
112
+ end
113
+
114
+ unless replacement_found
115
+ output_file.write(line)
116
+ end
117
+ end
118
+ end
119
+
120
+ def repackage_gem
121
+ # Get the original gem specification
122
+ original_spec = Gem::Package.new(@gem_path).spec
123
+
124
+ # Create a gemspec file from the original specification
125
+ gemspec_path = File.join(@gem_dir, "#{original_spec.name}.gemspec")
126
+ create_gemspec_file(gemspec_path, original_spec)
127
+
128
+ # Build the new gem using gem build command
129
+ _, stderr, status = Open3.capture3("gem", "build", File.basename(gemspec_path), chdir: @gem_dir)
130
+ unless status.success?
131
+ raise "Failed to build gem. Error: #{stderr}"
132
+ end
133
+
134
+ # Find the newly created gem file
135
+ gem_name = File.basename(@gem_path, ".gem")
136
+ new_gem_path = File.join(@gem_dir, "#{gem_name}.gem")
137
+
138
+ # Move to a more permanent location
139
+ final_gem_path = File.join(Dir.tmpdir, "#{gem_name}-repacked.gem")
140
+ FileUtils.mv(new_gem_path, final_gem_path)
141
+
142
+ final_gem_path
143
+ end
144
+
145
+ def create_gemspec_file(gemspec_path, spec)
146
+ # Create a new spec with additional metadata
147
+ new_spec = spec.dup
148
+
149
+ # Add additional metadata keys
150
+ @gemspec_extras.each do |key, value|
151
+ new_spec.metadata[key] = value
152
+ end
153
+
154
+ # Add injected files to the files list
155
+ @files.each do |file_path, _content|
156
+ new_spec.files << file_path unless new_spec.files.include?(file_path)
157
+ end
158
+
159
+ # Use the built-in to_ruby method to safely serialize the specification
160
+ gemspec_content = new_spec.to_ruby
161
+
162
+ File.write(gemspec_path, gemspec_content)
163
+ end
164
+
165
+ def cleanup
166
+ FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir)
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,53 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Paquette
5
+ class GemServer
6
+ # Abstract base class for gem repositories
7
+ class GemRepository
8
+ def initialize
9
+ raise NotImplementedError, "GemRepository is an abstract class"
10
+ end
11
+
12
+ # Returns an array of gem names available in the repository
13
+ def gem_names
14
+ raise NotImplementedError, "Subclasses must implement gem_names"
15
+ end
16
+
17
+ # Returns an array of [name, version] pairs for all gems
18
+ def gem_versions
19
+ raise NotImplementedError, "Subclasses must implement gem_versions"
20
+ end
21
+
22
+ # Returns an array of versions for a specific gem
23
+ def versions_for_gem(gem_name)
24
+ raise NotImplementedError, "Subclasses must implement versions_for_gem"
25
+ end
26
+
27
+ # Returns the gem file path for a specific gem and version
28
+ def gem_file_path(gem_name, version)
29
+ raise NotImplementedError, "Subclasses must implement gem_file_path"
30
+ end
31
+
32
+ # Returns whether a gem file exists for the given name and version
33
+ def gem_exists?(gem_name, version)
34
+ raise NotImplementedError, "Subclasses must implement gem_exists?"
35
+ end
36
+
37
+ # Returns the gem specification for a specific gem and version
38
+ def gem_spec(gem_name, version)
39
+ raise NotImplementedError, "Subclasses must implement gem_spec"
40
+ end
41
+
42
+ # Returns dependencies for a specific gem and version
43
+ def gem_dependencies(gem_name, version)
44
+ raise NotImplementedError, "Subclasses must implement gem_dependencies"
45
+ end
46
+
47
+ # Returns compact info for a specific gem (all versions)
48
+ def compact_info(gem_name)
49
+ raise NotImplementedError, "Subclasses must implement compact_info"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,66 @@
1
+ require "delegate"
2
+ require "fileutils"
3
+ require "digest"
4
+
5
+ module Paquette
6
+ class GemServer
7
+ class Personalizer < SimpleDelegator
8
+ def initialize(repository, license_key:, magic_comment_replacements: {})
9
+ super(repository)
10
+ @license_key = license_key
11
+ @magic_comment_replacements = magic_comment_replacements
12
+ end
13
+
14
+ def gem_file_path(gem_name, version)
15
+ original_path = __getobj__.gem_file_path(gem_name, version)
16
+ return original_path unless File.exist?(original_path)
17
+
18
+ # Create personalized version
19
+ personalize_gem(original_path, gem_name, version)
20
+ end
21
+
22
+ def compact_info(gem_name)
23
+ # Override compact_info to use personalized gems for checksums
24
+ versions = __getobj__.versions_for_gem(gem_name)
25
+ return [] if versions.empty?
26
+
27
+ versions.map do |version|
28
+ spec = __getobj__.gem_spec(gem_name, version)
29
+ next unless spec
30
+
31
+ # Use personalized gem file for checksum calculation
32
+ personalized_gem_file = gem_file_path(gem_name, version)
33
+ checksum = Digest::SHA256.file(personalized_gem_file).hexdigest
34
+
35
+ # Get required Ruby version from gemspec
36
+ ruby_version = spec.required_ruby_version&.to_s || ">= 0"
37
+
38
+ # Format: version |checksum:sha256_checksum,ruby:required_ruby_version
39
+ "#{version} |checksum:#{checksum},ruby:#{ruby_version}"
40
+ end.compact
41
+ end
42
+
43
+ private
44
+
45
+ def personalize_gem(original_gem_path, gem_name, version)
46
+ # Create a unique path for the personalized gem
47
+ personalized_dir = File.join(Dir.tmpdir, "paquette_personalized")
48
+ FileUtils.mkdir_p(personalized_dir)
49
+
50
+ personalized_path = File.join(personalized_dir, "#{gem_name}-#{version}-personalized.gem")
51
+
52
+ Paquette::GemServer::GemRepacker.repack(original_gem_path,
53
+ gemspec_extras: {"paquette.license_key" => @license_key},
54
+ magic_comment_replacements: @magic_comment_replacements)
55
+
56
+ # Move the repacked gem to our personalized location
57
+ temp_personalized = File.join(Dir.tmpdir, "#{gem_name}-#{version}-repacked.gem")
58
+ if File.exist?(temp_personalized)
59
+ FileUtils.mv(temp_personalized, personalized_path)
60
+ end
61
+
62
+ personalized_path
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,74 @@
1
+ require "delegate"
2
+
3
+ module Paquette
4
+ class GemServer
5
+ # Wraps a gem repository and filters every read through an entitler block.
6
+ # The block receives `name:` (and optionally `version:`) and returns truthy
7
+ # when the caller is allowed to see that gem/version. Non-entitled gems
8
+ # disappear from listings, return nil paths, and report as non-existent.
9
+ #
10
+ # Writes (add_gem, yank_gem) always raise WriteNotAllowed. Gating a read
11
+ # path implies the caller is not the right party to mutate the corpus —
12
+ # if you want a more nuanced story (e.g. read-gated but write-allowed
13
+ # for authenticated admins), write a different wrapper with its own
14
+ # policy. This one is deliberately opinionated: gating reads blocks
15
+ # writes, full stop.
16
+ class ReadGatedRepository < SimpleDelegator
17
+ class WriteNotAllowed < StandardError; end
18
+
19
+ def initialize(repository, &entitler)
20
+ super(repository)
21
+ @entitler = entitler
22
+ end
23
+
24
+ def gem_names
25
+ super.select { |name| @entitler.call(name: name) }
26
+ end
27
+
28
+ def gem_versions
29
+ super.select do |gem_name, version|
30
+ @entitler.call(name: gem_name, version: version)
31
+ end
32
+ end
33
+
34
+ def versions_for_gem(gem_name)
35
+ return [] unless @entitler.call(name: gem_name)
36
+ super.select do |version|
37
+ @entitler.call(name: gem_name, version: version)
38
+ end
39
+ end
40
+
41
+ def gem_file_path(gem_name, version)
42
+ if @entitler.call(name: gem_name, version: version)
43
+ super
44
+ end
45
+ end
46
+
47
+ def compact_info(gem_name)
48
+ return nil unless @entitler.call(name: gem_name)
49
+
50
+ all_info = super
51
+ return all_info unless all_info.is_a?(Array)
52
+
53
+ all_info.select do |line|
54
+ version = line.split(" ")[0]
55
+ @entitler.call(name: gem_name, version: version)
56
+ end
57
+ end
58
+
59
+ def gem_exists?(gem_name, version)
60
+ if @entitler.call(name: gem_name, version: version)
61
+ super
62
+ end
63
+ end
64
+
65
+ def add_gem(*)
66
+ raise WriteNotAllowed, "Writes are not allowed through a read-gated repository"
67
+ end
68
+
69
+ def yank_gem(*)
70
+ raise WriteNotAllowed, "Writes are not allowed through a read-gated repository"
71
+ end
72
+ end
73
+ end
74
+ end