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.
- checksums.yaml +7 -0
- data/LICENSE.md +10 -0
- data/README.md +90 -0
- data/lib/paquette/gem_server/directory_gem_repository.rb +154 -0
- data/lib/paquette/gem_server/gem_repacker.rb +170 -0
- data/lib/paquette/gem_server/gem_repository.rb +53 -0
- data/lib/paquette/gem_server/personalizer.rb +66 -0
- data/lib/paquette/gem_server/read_gated_repository.rb +74 -0
- data/lib/paquette/gem_server.rb +393 -0
- data/lib/paquette/npm_repacker.rb +153 -0
- data/lib/paquette/npm_server/directory_npm_repository.rb +127 -0
- data/lib/paquette/npm_server/gated_npm_repository.rb +66 -0
- data/lib/paquette/npm_server/npm_repository.rb +53 -0
- data/lib/paquette/npm_server.rb +98 -0
- data/lib/paquette/routes.rb +78 -0
- data/lib/paquette/subdomain_router.rb +59 -0
- data/lib/paquette/version.rb +3 -0
- data/lib/paquette.rb +12 -0
- data/test/fixtures/gems/minuscule_test/minuscule_test-0.1.0.gem +0 -0
- data/test/fixtures/gems/zip_kit/zip_kit-6.2.0.gem +0 -0
- data/test/fixtures/gems/zip_kit/zip_kit-6.2.1.gem +0 -0
- data/test/fixtures/npm/react-dropzone/react-dropzone-14.3.8.tgz +0 -0
- data/test/gem_server/bundler_install_test.rb +86 -0
- data/test/gem_server/directory_gem_repository_test.rb +208 -0
- data/test/gem_server/gem_repacker_test.rb +289 -0
- data/test/gem_server/personalizer_metadata_test.rb +49 -0
- data/test/gem_server/personalizer_test.rb +66 -0
- data/test/gem_server/read_gated_repository_test.rb +254 -0
- data/test/gem_server/repository_stack_test.rb +71 -0
- data/test/gem_server_test.rb +339 -0
- data/test/npm_repacker_sourcemap_test.rb +108 -0
- data/test/npm_repacker_test.rb +117 -0
- data/test/npm_server_test.rb +140 -0
- data/test/routes_test.rb +142 -0
- data/test/subdomain_router_integration_test.rb +126 -0
- data/test/subdomain_router_test.rb +125 -0
- data/test/test_helper.rb +12 -0
- 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
|