pray-cli 1.6.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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +125 -0
  5. data/SECURITY.md +28 -0
  6. data/bin/polyrun +108 -0
  7. data/bin/pray +26 -0
  8. data/lib/pray/archive.rb +68 -0
  9. data/lib/pray/auth_client.rb +97 -0
  10. data/lib/pray/cli/commands/auth.rb +42 -0
  11. data/lib/pray/cli/commands/distribution.rb +17 -0
  12. data/lib/pray/cli/commands/init.rb +62 -0
  13. data/lib/pray/cli/commands/meta.rb +15 -0
  14. data/lib/pray/cli/commands/packages.rb +107 -0
  15. data/lib/pray/cli/commands/trust.rb +73 -0
  16. data/lib/pray/cli/commands/workflow.rb +126 -0
  17. data/lib/pray/cli/help.rb +168 -0
  18. data/lib/pray/cli/helpers.rb +166 -0
  19. data/lib/pray/cli/parse.rb +136 -0
  20. data/lib/pray/cli/parse_auth.rb +91 -0
  21. data/lib/pray/cli/parse_trust.rb +152 -0
  22. data/lib/pray/cli/suggest.rb +57 -0
  23. data/lib/pray/cli.rb +117 -0
  24. data/lib/pray/confess.rb +113 -0
  25. data/lib/pray/config.rb +52 -0
  26. data/lib/pray/constraint.rb +80 -0
  27. data/lib/pray/destination.rb +158 -0
  28. data/lib/pray/dotenv.rb +46 -0
  29. data/lib/pray/environment.rb +41 -0
  30. data/lib/pray/error.rb +55 -0
  31. data/lib/pray/format_manifest.rb +255 -0
  32. data/lib/pray/format_serialize.rb +135 -0
  33. data/lib/pray/git_sources.rb +228 -0
  34. data/lib/pray/hashing.rb +59 -0
  35. data/lib/pray/invocation.rb +110 -0
  36. data/lib/pray/literal.rb +382 -0
  37. data/lib/pray/lockfile.rb +259 -0
  38. data/lib/pray/lockfile_serialize.rb +125 -0
  39. data/lib/pray/manifest.rb +126 -0
  40. data/lib/pray/manifest_formatter.rb +56 -0
  41. data/lib/pray/manifest_json.rb +128 -0
  42. data/lib/pray/manifest_parser.rb +152 -0
  43. data/lib/pray/manifest_parser_blocks.rb +216 -0
  44. data/lib/pray/manifest_parser_helpers.rb +208 -0
  45. data/lib/pray/materialize.rb +120 -0
  46. data/lib/pray/package_spec.rb +245 -0
  47. data/lib/pray/path_safety.rb +41 -0
  48. data/lib/pray/plan.rb +83 -0
  49. data/lib/pray/project_context.rb +67 -0
  50. data/lib/pray/publish.rb +162 -0
  51. data/lib/pray/registry.rb +355 -0
  52. data/lib/pray/render.rb +360 -0
  53. data/lib/pray/resolve.rb +396 -0
  54. data/lib/pray/resolve_context.rb +19 -0
  55. data/lib/pray/serve.rb +130 -0
  56. data/lib/pray/serve_federation.rb +109 -0
  57. data/lib/pray/session.rb +90 -0
  58. data/lib/pray/ssh_agent.rb +115 -0
  59. data/lib/pray/statement_surface.rb +262 -0
  60. data/lib/pray/substitute.rb +50 -0
  61. data/lib/pray/sync.rb +219 -0
  62. data/lib/pray/terminal.rb +30 -0
  63. data/lib/pray/trust.rb +201 -0
  64. data/lib/pray/trust_feed.rb +110 -0
  65. data/lib/pray/trust_ops.rb +202 -0
  66. data/lib/pray/verify.rb +257 -0
  67. data/lib/pray/version.rb +6 -0
  68. data/lib/pray-cli.rb +4 -0
  69. data/lib/pray.rb +46 -0
  70. data/pray-cli.gemspec +48 -0
  71. metadata +187 -0
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "fileutils"
7
+ require "pathname"
8
+ require_relative "path_safety"
9
+
10
+ module Pray
11
+ RegistryPackageVersion = Struct.new(
12
+ :version, :artifact, :artifact_hash, :tree_hash, :yanked, :targets, :exports,
13
+ :signer, :signer_fingerprint, :published_at, :signature
14
+ ) do
15
+ def initialize(
16
+ artifact_hash: nil, tree_hash: nil, yanked: false, targets: [], exports: [],
17
+ signer: nil, signer_fingerprint: nil, published_at: nil, signature: nil, **kwargs
18
+ )
19
+ super(**kwargs, artifact_hash: artifact_hash, tree_hash: tree_hash, yanked: yanked,
20
+ targets: targets, exports: exports, signer: signer,
21
+ signer_fingerprint: signer_fingerprint, published_at: published_at, signature: signature)
22
+ end
23
+ end
24
+
25
+ RegistryPackageMetadata = Struct.new(:name, :versions) do
26
+ def initialize(name: nil, versions: [])
27
+ super
28
+ end
29
+ end
30
+
31
+ RegistryPackageResolution = Struct.new(
32
+ :root, :signer_fingerprint, :registry_latest_version
33
+ )
34
+
35
+ module Registry
36
+ module_function
37
+
38
+ def resolve_registry_package_root(project_root, source_url, declaration, preferred_version: nil, offline: false)
39
+ metadata = fetch_package_metadata(source_url, declaration.name)
40
+ registry_latest_version = registry_latest_version_label(metadata)
41
+ selected = select_package_version(metadata, declaration.constraint, preferred_version)
42
+ cache_directory = registry_cache_directory(
43
+ project_root,
44
+ source_url,
45
+ declaration.name,
46
+ selected.version,
47
+ selected.artifact_hash
48
+ )
49
+
50
+ if cache_ready?(cache_directory, selected)
51
+ return RegistryPackageResolution.new(
52
+ root: cache_directory,
53
+ signer_fingerprint: selected.signer_fingerprint,
54
+ registry_latest_version: registry_latest_version
55
+ )
56
+ end
57
+
58
+ raise Error.resolution(offline_package_error(declaration.name, selected.version)) if offline
59
+
60
+ FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
61
+ FileUtils.mkdir_p(cache_directory)
62
+
63
+ artifact_bytes = read_artifact_bytes(source_url, selected.artifact)
64
+ validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: source_url)
65
+
66
+ RegistryPackageResolution.new(
67
+ root: cache_directory,
68
+ signer_fingerprint: selected.signer_fingerprint,
69
+ registry_latest_version: registry_latest_version
70
+ )
71
+ end
72
+
73
+ def resolve_local_registry_package_root(project_root, source_key, source_root, declaration, preferred_version: nil, offline: false)
74
+ metadata_path = registry_metadata_path(source_root, declaration.name)
75
+ unless File.exist?(metadata_path)
76
+ raise Error.resolution(
77
+ "package #{declaration.name} not found in distribution #{source_root.inspect}. " \
78
+ "Missing #{metadata_path}. Check the package name, version constraint `#{declaration.constraint}`, " \
79
+ "and that the source publishes registry metadata."
80
+ )
81
+ end
82
+
83
+ metadata = parse_metadata(File.read(metadata_path, encoding: "UTF-8"))
84
+ registry_latest_version = registry_latest_version_label(metadata)
85
+ selected = select_package_version(metadata, declaration.constraint, preferred_version)
86
+ cache_directory = registry_cache_directory(
87
+ project_root,
88
+ source_key,
89
+ declaration.name,
90
+ selected.version,
91
+ selected.artifact_hash
92
+ )
93
+
94
+ if cache_ready?(cache_directory, selected)
95
+ return RegistryPackageResolution.new(
96
+ root: cache_directory,
97
+ signer_fingerprint: selected.signer_fingerprint,
98
+ registry_latest_version: registry_latest_version
99
+ )
100
+ end
101
+
102
+ raise Error.resolution(offline_package_error(declaration.name, selected.version)) if offline
103
+
104
+ FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
105
+ FileUtils.mkdir_p(cache_directory)
106
+
107
+ artifact_bytes = read_local_artifact_bytes(source_root, selected.artifact)
108
+ validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: source_root)
109
+
110
+ RegistryPackageResolution.new(
111
+ root: cache_directory,
112
+ signer_fingerprint: selected.signer_fingerprint,
113
+ registry_latest_version: registry_latest_version
114
+ )
115
+ end
116
+
117
+ def fetch_package_metadata(source_url, package_name)
118
+ if local_source?(source_url)
119
+ metadata_path = registry_metadata_path(source_url, package_name)
120
+ return parse_metadata(File.read(metadata_path, encoding: "UTF-8"))
121
+ end
122
+
123
+ PathSafety.reject_unsafe_package_name!(package_name)
124
+ response = http_get(join_url(source_url, "v1/packages/#{package_name}.json"))
125
+ parse_metadata(response)
126
+ end
127
+
128
+ def parse_metadata(text)
129
+ data = JSON.parse(text)
130
+ RegistryPackageMetadata.new(
131
+ name: data["name"],
132
+ versions: Array(data["versions"]).map { |entry| version_from_hash(entry) }
133
+ )
134
+ end
135
+
136
+ def version_from_hash(entry)
137
+ RegistryPackageVersion.new(
138
+ version: entry["version"],
139
+ artifact: entry["artifact"],
140
+ artifact_hash: entry["artifact_hash"],
141
+ tree_hash: entry["tree_hash"],
142
+ yanked: entry["yanked"] || false,
143
+ targets: entry["targets"] || [],
144
+ exports: entry["exports"] || [],
145
+ signer: entry["signer"],
146
+ signer_fingerprint: entry["signer_fingerprint"],
147
+ published_at: entry["published_at"],
148
+ signature: entry["signature"]
149
+ )
150
+ end
151
+
152
+ def select_package_version(metadata, constraint, preferred_version)
153
+ if preferred_version
154
+ version = metadata.versions.find { |entry| entry.version == preferred_version && !entry.yanked }
155
+ return version if version && Constraint.version_satisfies(version.version, constraint)
156
+ end
157
+
158
+ selected = nil
159
+ metadata.versions.each do |version|
160
+ next if version.yanked
161
+ next unless Constraint.version_satisfies(version.version, constraint)
162
+
163
+ selected = version if selected.nil? || compare_versions(version.version, selected.version).positive?
164
+ end
165
+
166
+ raise Error.resolution("no registry version for #{metadata.name} satisfies #{constraint}") unless selected
167
+
168
+ selected
169
+ end
170
+
171
+ def registry_latest_version_label(metadata)
172
+ highest = metadata.versions.reject(&:yanked).max_by { |entry| Gem::Version.new(entry.version) }
173
+ highest&.version
174
+ end
175
+
176
+ def compare_versions(left, right)
177
+ Gem::Version.new(left) <=> Gem::Version.new(right)
178
+ end
179
+
180
+ def validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: nil)
181
+ if selected.artifact_hash
182
+ artifact_hash = Hashing.sha256_prefixed(artifact_bytes)
183
+ if artifact_hash != selected.artifact_hash
184
+ raise Error.integrity(
185
+ "package artifact hash mismatch for #{declaration.name} #{selected.version}"
186
+ )
187
+ end
188
+ end
189
+
190
+ verify_registry_signature!(declaration, selected, artifact_bytes)
191
+ Trust.verify_publisher_fingerprint!(source_url, selected) if source_url
192
+
193
+ Archive.unpack_praypkg(artifact_bytes, cache_directory)
194
+ spec_path = Resolve.find_prayspec_file(cache_directory)
195
+ spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
196
+ if spec.name != declaration.name
197
+ raise Error.resolution(
198
+ "package path #{cache_directory.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
199
+ )
200
+ end
201
+ if spec.version != selected.version
202
+ raise Error.resolution(
203
+ "package #{declaration.name} version #{spec.version} does not match registry version #{selected.version}"
204
+ )
205
+ end
206
+ if selected.tree_hash
207
+ actual_tree_hash = spec.tree_hash_for_root(cache_directory)
208
+ if actual_tree_hash != selected.tree_hash
209
+ raise Error.integrity(
210
+ "package tree hash mismatch for #{declaration.name} #{selected.version}"
211
+ )
212
+ end
213
+ end
214
+ end
215
+
216
+ def read_local_artifact_bytes(source_root, artifact)
217
+ if artifact.start_with?("http://", "https://")
218
+ return http_get(artifact)
219
+ end
220
+ if artifact.start_with?("file://")
221
+ path = PathSafety.join_under_root(source_root, artifact.delete_prefix("file://"))
222
+ raise Error.resolution("package artifact path escapes distribution root") unless path
223
+ return File.binread(path)
224
+ end
225
+
226
+ path = PathSafety.join_under_root(source_root, artifact)
227
+ raise Error.resolution("package artifact path escapes distribution root") unless path
228
+ raise Error.resolution("package artifact missing at #{path}") unless File.exist?(path)
229
+
230
+ File.binread(path)
231
+ end
232
+
233
+ def read_artifact_bytes(source_url, artifact)
234
+ return read_local_artifact_bytes(source_url, artifact) if local_source?(source_url)
235
+
236
+ http_get(join_url(source_url, artifact))
237
+ end
238
+
239
+ def cache_ready?(cache_directory, selected)
240
+ return false unless File.directory?(cache_directory)
241
+
242
+ spec_path = Resolve.find_prayspec_file(cache_directory)
243
+ spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
244
+ spec.version == selected.version
245
+ rescue Errno::ENOENT, Errno::ENOTDIR, SystemCallError
246
+ false
247
+ end
248
+
249
+ def registry_metadata_path(source_root, package_name)
250
+ PathSafety.reject_unsafe_package_name!(package_name)
251
+ packages_root = PathSafety.join_under_root(source_root, "v1", "packages")
252
+ raise Error.resolution("invalid package name: #{package_name.inspect}") unless packages_root
253
+
254
+ metadata_path = Pathname.new(packages_root).join("#{package_name}.json").cleanpath.to_s
255
+ unless PathSafety.path_under_root?(packages_root, metadata_path)
256
+ raise Error.resolution("invalid package name: #{package_name.inspect}")
257
+ end
258
+
259
+ metadata_path
260
+ end
261
+
262
+ def verify_registry_signature!(declaration, selected, artifact_bytes)
263
+ return unless selected.signature
264
+
265
+ unless selected.signer && selected.tree_hash
266
+ raise Error.integrity(
267
+ "package signature metadata incomplete for #{declaration.name} #{selected.version}"
268
+ )
269
+ end
270
+
271
+ expected = registry_artifact_signature(artifact_bytes, selected.tree_hash, selected.signer)
272
+ return if expected == selected.signature
273
+
274
+ raise Error.integrity(
275
+ "package signature mismatch for #{declaration.name} #{selected.version}"
276
+ )
277
+ end
278
+
279
+ def registry_cache_directory(project_root, source_key, package_name, version, artifact_hash)
280
+ identifier = [
281
+ source_key,
282
+ package_name,
283
+ version,
284
+ artifact_hash || "no-artifact-hash"
285
+ ].join(":")
286
+ digest = Hashing.sha256_hex(identifier)[0, 16]
287
+ File.join(project_root, ".pray", "cache", "registry", package_name.tr("/", "-"), version, digest)
288
+ end
289
+
290
+ def offline_package_error(package_name, version)
291
+ "package #{package_name} #{version} is not cached and offline mode is enabled"
292
+ end
293
+
294
+ def local_source?(source_url)
295
+ !source_url.start_with?("http://", "https://", "pray+ssh://", "ssh+pray://")
296
+ end
297
+
298
+ def join_url(base, path)
299
+ URI.join(base.end_with?("/") ? base : "#{base}/", path.delete_prefix("/")).to_s
300
+ end
301
+
302
+ def http_get(url)
303
+ uri = URI(url)
304
+ response = http_request(uri) { |http| http.get(uri.request_uri) }
305
+ unless response.is_a?(Net::HTTPSuccess)
306
+ raise Error.resolution("HTTP request failed for #{url}: #{response.code}")
307
+ end
308
+
309
+ response.body
310
+ end
311
+
312
+ def http_put(url, content_type, body)
313
+ uri = URI(url)
314
+ request = Net::HTTP::Put.new(uri)
315
+ request["Content-Type"] = content_type
316
+ request.body = body
317
+ response = http_request(uri) { |http| http.request(request) }
318
+ unless response.is_a?(Net::HTTPSuccess)
319
+ raise Error.resolution("HTTP upload failed for #{url}: #{response.code}")
320
+ end
321
+
322
+ response.body
323
+ end
324
+
325
+ def http_post(url, content_type, body)
326
+ uri = URI(url)
327
+ request = Net::HTTP::Post.new(uri)
328
+ request["Content-Type"] = content_type
329
+ request.body = body
330
+ response = http_request(uri) { |http| http.request(request) }
331
+ unless response.is_a?(Net::HTTPSuccess)
332
+ raise Error.resolution("HTTP request failed for #{url}: #{response.code}")
333
+ end
334
+
335
+ response.body
336
+ end
337
+
338
+ def http_request(uri)
339
+ Net::HTTP.start(
340
+ uri.hostname,
341
+ uri.port,
342
+ use_ssl: uri.scheme == "https",
343
+ open_timeout: 10,
344
+ read_timeout: 30
345
+ ) do |http|
346
+ yield http
347
+ end
348
+ end
349
+
350
+ def registry_artifact_signature(artifact_bytes, tree_hash, signer)
351
+ payload = artifact_bytes + "\0".b + tree_hash.b + "\0".b + signer.b
352
+ Hashing.sha256_prefixed(payload)
353
+ end
354
+ end
355
+ end