pray-cli 1.22.0 → 1.23.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98ff5ea4c8847034ac02621b75593119ad7b5810fdde65e25998f0702b326a4a
4
- data.tar.gz: a6f9245375fad4ae3b49eeb71e97cb51ccd4554dcd0e98e7acf64cc8b1d1864c
3
+ metadata.gz: 9fa10db14b7b45fdd5152a06e02fdf4312ca40fa575529ecd7448722caea437c
4
+ data.tar.gz: f53a2e0ca8b6d8a5d97a8a45903953a9b20c900864a354b8ea494e986eb9d815
5
5
  SHA512:
6
- metadata.gz: a7cf4893383addd2ad24b02f36be80543e4fac33ef93b0f606de2ee4370b4be8998b361ed9739d43424aa872e9c1748009fd04f176aa28a64e2cddda15249b3b
7
- data.tar.gz: f4c71dccd6a7f7faf2e4a266893ff693ffdd10827061c85213c87dacd167fc0482d7fac32e67db6ed868809d6a02dff1c9f4fc8b4275d8f74c511be394bb782e
6
+ metadata.gz: 21c44b12b0e8bf97d429491f4c32969a6700857c28b2b4b0981312dab4032279f7e52b4a4bd050984578469287554497e0ccff96a36ff6a3f99e69b97bbe1790
7
+ data.tar.gz: 11b795d9454a35f763cab755f82747c4470303912ef4269dd399d0b89266ad6833cf2246462b39113acfaccc494b904eba026d0e3813709a5e62735fcb74617e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.23.0 (2026-10-26)
6
+
7
+ - Leave package catalog JSON bytes alone when publish reports a version unchanged. A load and rewrite round trip no longer strips trailing newlines or reorders keys such as `artifact_hash` relative to `yanked`.
8
+ - Refuse private, parent-relative, and symbolic-link paths from distribution readers. Reject symbolic-link source files and package paths.
9
+ - Treat `~> 1.4` as allowing later 1.x releases and keep `~> 1.4.0` within the 1.4 patch line.
10
+ - Render every legacy target output and enforce `max_bytes`.
11
+ - Refuse two packages or exports that claim the same provisioned destination, and name both owners in the error.
12
+ - Emit valid TOML and JSON escapes for lock and manifest values containing control characters.
13
+ - Reduce repeated parser and render scans.
14
+
5
15
  ## 1.22.0 (2026-10-22)
6
16
 
7
17
  - Keep and create catalog `derived_metadata` on publish. Unchanged republishes and version appends no longer strip historical summaries, topics, or embeddings.
@@ -74,7 +74,7 @@ module Pray
74
74
  lower = numbers.join(".")
75
75
  upper = case parts.length
76
76
  when 1 then "#{numbers[0] + 1}.0.0"
77
- when 2 then "#{numbers[0]}.#{numbers[1] + 1}.0"
77
+ when 2 then "#{numbers[0] + 1}.0.0"
78
78
  else "#{numbers[0]}.#{numbers[1] + 1}.0"
79
79
  end
80
80
  ">= #{lower}, < #{upper}"
@@ -41,9 +41,15 @@ module Pray
41
41
  target
42
42
  end
43
43
 
44
- def upsert_package(manifest, package)
45
- existing = manifest.packages.find { |candidate| candidate.name == package.name }
44
+ def upsert_package(manifest, package, package_indices = nil)
45
+ existing_index = package_indices&.[](package.name)
46
+ existing = if existing_index
47
+ manifest.packages[existing_index]
48
+ elsif package_indices.nil?
49
+ manifest.packages.find { |candidate| candidate.name == package.name }
50
+ end
46
51
  unless existing
52
+ package_indices[package.name] = manifest.packages.length if package_indices
47
53
  manifest.packages << package
48
54
  return
49
55
  end
@@ -93,9 +99,15 @@ module Pray
93
99
  end
94
100
  end
95
101
 
96
- def upsert_local(manifest, local)
97
- existing = manifest.local.find { |candidate| candidate.path == local.path }
102
+ def upsert_local(manifest, local, local_indices = nil)
103
+ existing_index = local_indices&.[](local.path)
104
+ existing = if existing_index
105
+ manifest.local[existing_index]
106
+ elsif local_indices.nil?
107
+ manifest.local.find { |candidate| candidate.path == local.path }
108
+ end
98
109
  unless existing
110
+ local_indices[local.path] = manifest.local.length if local_indices
99
111
  manifest.local << local
100
112
  return
101
113
  end
@@ -108,17 +120,25 @@ module Pray
108
120
  existing.file ||= local.file
109
121
  end
110
122
 
111
- def bind_package_entry(target, package_name)
123
+ def bind_package_entry(target, package_name, package_names = nil)
112
124
  entry = DestinationEntry.package(package_name)
113
- return if target.entries.any? { |candidate| candidate.kind == "package" && candidate.name == package_name }
125
+ exists = package_names ? package_names.key?(package_name) : target.entries.any? do |candidate|
126
+ candidate.kind == "package" && candidate.name == package_name
127
+ end
128
+ return if exists
114
129
 
115
130
  target.entries << entry
131
+ package_names[package_name] = true if package_names
116
132
  end
117
133
 
118
- def bind_local_entry(target, path)
119
- return if target.entries.any? { |candidate| candidate.kind == "local" && candidate.path == path }
134
+ def bind_local_entry(target, path, local_paths = nil)
135
+ exists = local_paths ? local_paths.key?(path) : target.entries.any? do |candidate|
136
+ candidate.kind == "local" && candidate.path == path
137
+ end
138
+ return if exists
120
139
 
121
140
  target.entries << DestinationEntry.local(path)
141
+ local_paths[path] = true if local_paths
122
142
  end
123
143
 
124
144
  def role_for_destination(mode)
data/lib/pray/literal.rb CHANGED
@@ -81,7 +81,7 @@ module Pray
81
81
  when "]", "}", ")"
82
82
  depth -= 1
83
83
  else
84
- return index if depth.zero? && input[index..].start_with?(token)
84
+ return index if depth.zero? && input[index, token.length] == token
85
85
  end
86
86
  index += 1
87
87
  end
@@ -151,6 +151,7 @@ module Pray
151
151
  end
152
152
 
153
153
  def strip_line_comment(line)
154
+ return line unless line.include?("#")
154
155
  quote = nil
155
156
  escaped = false
156
157
  line.each_char.with_index do |character, index|
@@ -190,7 +191,7 @@ module Pray
190
191
  end
191
192
  end
192
193
 
193
- def peek = remaining[0]
194
+ def peek = @input[@cursor]
194
195
 
195
196
  def next_character
196
197
  character = peek
@@ -370,9 +371,7 @@ module Pray
370
371
  LiteralValue.new(kind: :string, value: @input[start...@cursor])
371
372
  end
372
373
 
373
- def identifier_start?(character)
374
- character && (character.match?(/[[:alpha:]]/) || character == "_")
375
- end
374
+ def identifier_start?(character) = character && (character.match?(/[[:alpha:]]/) || character == "_")
376
375
 
377
376
  def identifier_continue?(character)
378
377
  character.match?(%r{[[:alnum:]_\-.\\/]})
@@ -144,7 +144,12 @@ module Pray
144
144
  end
145
145
 
146
146
  def format_string(value)
147
- escaped = value.gsub("\\", "\\\\").gsub("\"", "\\\"")
147
+ escapes = {8 => "\\b", 9 => "\\t", 10 => "\\n", 12 => "\\f", 13 => "\\r", 34 => "\\\"", 92 => "\\\\"}
148
+ escaped = value.each_codepoint.map do |codepoint|
149
+ escapes.fetch(codepoint) do
150
+ (codepoint < 32 || codepoint == 127) ? format("\\u%04X", codepoint) : codepoint.chr(Encoding::UTF_8)
151
+ end
152
+ end.join
148
153
  "\"#{escaped}\""
149
154
  end
150
155
 
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Pray
4
6
  module ManifestJson
5
7
  module_function
6
8
 
7
9
  def encode_compact(manifest)
8
- encode_object(manifest_fields(manifest))
10
+ JSON.generate(manifest_fields(manifest))
9
11
  end
10
12
 
11
13
  def manifest_fields(manifest)
@@ -109,31 +111,5 @@ module Pray
109
111
  "header" => render.header
110
112
  }
111
113
  end
112
-
113
- def encode_object(hash)
114
- entries = hash.map { |key, value| "#{encode_string(key)}:#{encode_value(value)}" }
115
- "{#{entries.join(",")}}"
116
- end
117
-
118
- def encode_array(array)
119
- "[#{array.map { |value| encode_value(value) }.join(",")}]"
120
- end
121
-
122
- def encode_value(value)
123
- case value
124
- when Hash then encode_object(value)
125
- when Array then encode_array(value)
126
- when String then encode_string(value)
127
- when TrueClass, FalseClass then value.to_s
128
- when NilClass then "null"
129
- when Integer then value.to_s
130
- else
131
- raise Error.manifest("unsupported JSON value: #{value.inspect}")
132
- end
133
- end
134
-
135
- def encode_string(text)
136
- "\"#{text.gsub("\\", "\\\\").gsub("\"", "\\\"").gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")}\""
137
- end
138
114
  end
139
115
  end
@@ -12,6 +12,10 @@ module Pray
12
12
  @cursor = 0
13
13
  @group_stack = []
14
14
  @surface = StatementSurface::Reader.new
15
+ @package_indices = {}
16
+ @local_indices = {}
17
+ @target_package_names = {}
18
+ @target_local_paths = {}
15
19
  end
16
20
 
17
21
  def parse_root
@@ -59,7 +63,7 @@ module Pray
59
63
  when /\Aagent (.+)\z/
60
64
  apply_agent_package(manifest, Regexp.last_match(1))
61
65
  when /\Apackage (.+)\z/
62
- Destination.upsert_package(manifest, parse_package_with_groups(Regexp.last_match(1)))
66
+ upsert_package(manifest, parse_package_with_groups(Regexp.last_match(1)))
63
67
  when /\A(?:pray|use|include) (.+)\z/
64
68
  apply_pray_statement(manifest, Regexp.last_match(1), nil)
65
69
  when /\Alocal (.+)\z/
@@ -113,13 +117,13 @@ module Pray
113
117
 
114
118
  def apply_agent_package(manifest, rest)
115
119
  manifest.note_deprecated_keyword("agent")
116
- Destination.upsert_package(manifest, parse_package_with_groups(rest))
120
+ upsert_package(manifest, parse_package_with_groups(rest))
117
121
  end
118
122
 
119
123
  def apply_unbound_local(manifest, rest)
120
124
  local = parse_local_decl(rest)
121
125
  local.bound = false
122
- Destination.upsert_local(manifest, local)
126
+ upsert_local(manifest, local)
123
127
  end
124
128
 
125
129
  def parse_package_with_groups(rest)
@@ -128,6 +132,24 @@ module Pray
128
132
  package
129
133
  end
130
134
 
135
+ def upsert_package(manifest, package)
136
+ Destination.upsert_package(manifest, package, @package_indices)
137
+ end
138
+
139
+ def upsert_local(manifest, local)
140
+ Destination.upsert_local(manifest, local, @local_indices)
141
+ end
142
+
143
+ def bind_package_entry(target, name)
144
+ names = (@target_package_names[target.object_id] ||= {})
145
+ Destination.bind_package_entry(target, name, names)
146
+ end
147
+
148
+ def bind_local_entry(target, path)
149
+ paths = (@target_local_paths[target.object_id] ||= {})
150
+ Destination.bind_local_entry(target, path, paths)
151
+ end
152
+
131
153
  def next_statement
132
154
  pending = @surface.next
133
155
  return pending if pending
@@ -33,9 +33,9 @@ module Pray
33
33
  return if statement == "end"
34
34
  if (match = statement.match(/\Aagent (.+)\z/))
35
35
  manifest.note_deprecated_keyword("agent")
36
- Destination.upsert_package(manifest, parse_package_with_groups(match[1]))
36
+ upsert_package(manifest, parse_package_with_groups(match[1]))
37
37
  elsif (match = statement.match(/\A(?:package|pray|use|include) (.+)\z/))
38
- Destination.upsert_package(manifest, parse_package_with_groups(match[1]))
38
+ upsert_package(manifest, parse_package_with_groups(match[1]))
39
39
  elsif /\Agroup (.+)\z/.match?(statement)
40
40
  raise Error.parse("manifest", "nested group blocks are not supported")
41
41
  else
@@ -95,8 +95,8 @@ module Pray
95
95
  def bind_compose_local(manifest, index, rest)
96
96
  local = parse_local_decl(rest)
97
97
  local.bound = true
98
- Destination.bind_local_entry(manifest.targets[index], local.path)
99
- Destination.upsert_local(manifest, local)
98
+ bind_local_entry(manifest.targets[index], local.path)
99
+ upsert_local(manifest, local)
100
100
  end
101
101
 
102
102
  def parse_file_block(manifest, rest)
@@ -154,7 +154,7 @@ module Pray
154
154
  package.file = file_path
155
155
  package.bound = true
156
156
  package.roles << "file" unless package.roles.include?("file")
157
- Destination.upsert_package(manifest, package)
157
+ upsert_package(manifest, package)
158
158
  end
159
159
 
160
160
  def apply_pray_statement(manifest, rest, destination_index)
@@ -196,9 +196,9 @@ module Pray
196
196
  package.bound = true
197
197
  role = Destination.role_for_destination(mode)
198
198
  package.roles << role if role && !package.roles.include?(role)
199
- Destination.bind_package_entry(manifest.targets[destination_index], package.name)
199
+ bind_package_entry(manifest.targets[destination_index], package.name)
200
200
  end
201
- Destination.upsert_package(manifest, package)
201
+ upsert_package(manifest, package)
202
202
  end
203
203
 
204
204
  def parse_target_block(manifest, target_index)
data/lib/pray/publish.rb CHANGED
@@ -34,7 +34,7 @@ module Pray
34
34
  stored_artifact = stored_package_artifact(root, artifact_path, package, existing, distribution)
35
35
  if stored_artifact && stored_publish_matches?(stored_artifact, package, existing)
36
36
  package_names << package.declaration.name
37
- write_registry_package_metadata(metadata_path, metadata)
37
+ # Intent: unchanged packages keep on-disk catalog bytes (newline, key order).
38
38
  report_unchanged(package, dest_name, target)
39
39
  stats[:unchanged] += 1
40
40
  next
data/lib/pray/render.rb CHANGED
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "pathname"
5
5
  require_relative "render_content"
6
6
  require_relative "render_managed"
7
+ require_relative "render_project"
7
8
 
8
9
  module Pray
9
10
  RenderedTarget = Struct.new(:path, :content, :managed_spans) do
@@ -15,16 +16,6 @@ module Pray
15
16
  module Render
16
17
  module_function
17
18
 
18
- def render_project(project)
19
- rendered = []
20
- project.manifest.targets.each do |target|
21
- output = target.outputs.first
22
- next unless output
23
- rendered << render_target(project, target, output)
24
- end
25
- rendered
26
- end
27
-
28
19
  def write_rendered_targets(project, rendered, previous_lockfile = nil)
29
20
  Transaction.run(project.project_root) { RenderDest.write_rendered_targets(project, rendered, previous_lockfile) }
30
21
  end
@@ -56,7 +47,18 @@ module Pray
56
47
  end
57
48
  end
58
49
  end
59
- planned.sort_by(&:path).uniq(&:path)
50
+ planned.sort_by! { |file| [file.path, file.package, file.export] }
51
+ planned.each_cons(2) do |left, right|
52
+ next unless left.path == right.path
53
+ next if left.package == right.package && left.export == right.export
54
+
55
+ raise Error.render(
56
+ "provisioned destination `#{left.path}` has multiple owners: " \
57
+ "package `#{left.package}` export `#{left.export}` and " \
58
+ "package `#{right.package}` export `#{right.export}`"
59
+ )
60
+ end
61
+ planned.uniq(&:path)
60
62
  end
61
63
 
62
64
  PlannedProvisionedFile = Struct.new(:path, :source, :package, :export, keyword_init: true)
@@ -74,12 +76,23 @@ module Pray
74
76
  append_header(builder, project, target, output)
75
77
  symbols = project.manifest.symbols || {}
76
78
  managed_spans = []
79
+ locals_by_path = project.local_files.to_h { |local| [local.manifest_path, local] }
80
+ packages_by_name = project.packages.to_h { |package| [package.declaration.name, package] }
77
81
 
78
82
  (target.entries || []).each do |entry|
79
83
  if entry.kind == "local"
80
- append_scoped_local(builder, managed_spans, project, target, output, entry.path, symbols)
84
+ local = locals_by_path[entry.path]
85
+ append_managed_local(builder, managed_spans, local, target, output, symbols) if local
81
86
  else
82
- append_scoped_package(builder, managed_spans, project, target, output, entry.name, symbols)
87
+ package = packages_by_name[entry.name]
88
+ next unless package
89
+ next unless Environment.package_matches_environment?(package.declaration.groups, project.environment)
90
+
91
+ package.selected_exports.each do |export|
92
+ next unless should_inline_export?(package, export)
93
+
94
+ append_managed_export(builder, managed_spans, package, export, target, output, symbols)
95
+ end
83
96
  end
84
97
  end
85
98
 
@@ -133,25 +146,6 @@ module Pray
133
146
  builder.append_empty_line
134
147
  end
135
148
 
136
- def append_scoped_local(builder, managed_spans, project, target, output, path, symbols)
137
- local = project.local_files.find { |candidate| candidate.manifest_path == path }
138
- return unless local
139
-
140
- append_managed_local(builder, managed_spans, local, target, output, symbols)
141
- end
142
-
143
- def append_scoped_package(builder, managed_spans, project, target, output, package_name, symbols)
144
- package = project.packages.find { |candidate| candidate.declaration.name == package_name }
145
- return unless package
146
- return unless Environment.package_matches_environment?(package.declaration.groups, project.environment)
147
-
148
- package.selected_exports.each do |export|
149
- next unless should_inline_export?(package, export)
150
-
151
- append_managed_export(builder, managed_spans, package, export, target, output, symbols)
152
- end
153
- end
154
-
155
149
  def should_inline_export?(package, export_name)
156
150
  export = package.spec.exports[export_name]
157
151
  export.nil? || %w[fragment file].include?(export.kind)
@@ -3,16 +3,16 @@
3
3
  module Pray
4
4
  module Render
5
5
  class ContentBuilder
6
+ attr_reader :next_line_number
7
+
6
8
  def initialize
7
9
  @content = +""
8
- end
9
-
10
- def next_line_number
11
- @content.count("\n") + 1
10
+ @next_line_number = 1
12
11
  end
13
12
 
14
13
  def append_line(line)
15
14
  @content << line << "\n"
15
+ @next_line_number += 1
16
16
  end
17
17
 
18
18
  def append_empty_line
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Render
5
+ module_function
6
+
7
+ def render_project(project)
8
+ project.manifest.targets.flat_map do |target|
9
+ target.outputs.map do |output|
10
+ rendered = render_target(project, target, output)
11
+ if target.max_bytes && rendered.content.bytesize > target.max_bytes
12
+ raise Error.render(
13
+ "target `#{target.name}` output `#{output}` is #{rendered.content.bytesize} bytes; " \
14
+ "max_bytes is #{target.max_bytes}"
15
+ )
16
+ end
17
+ rendered
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/pray/resolve.rb CHANGED
@@ -154,8 +154,8 @@ module Pray
154
154
  project_root, sources, git_sources, user_config, declaration, lockfile, options
155
155
  )
156
156
  spec_path = find_prayspec_file(root)
157
- spec_text = File.read(spec_path)
158
- spec = Pray.parse_package_spec(spec_text).canonicalized
157
+ spec_text = SourcePath.read(root, File.basename(spec_path), "package specification").force_encoding(Encoding::UTF_8)
158
+ spec = Pray.parse_package_spec(spec_text)
159
159
  if spec.name != declaration.name
160
160
  raise Error.resolution(
161
161
  "package path #{root.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
@@ -195,9 +195,11 @@ module Pray
195
195
  offline = options.offline
196
196
  preferred_version = options.preferred_lock_version(lockfile, declaration.name)
197
197
  if (local_path = user_config.local.package[declaration.name])
198
- return [File.expand_path(local_path, project_root), nil]
198
+ return [SourcePath.declared_directory(project_root, local_path, "package directory"), nil]
199
+ end
200
+ if declaration.path
201
+ return [SourcePath.declared_directory(project_root, declaration.path, "package directory"), nil]
199
202
  end
200
- return [File.expand_path(declaration.path, project_root), nil] if declaration.path
201
203
  return [ResolveTarball.package_root(project_root, declaration.tarball, offline: offline), nil] if declaration.tarball
202
204
  source_name = ResolveSource.implied_source_name(declaration, sources)
203
205
  if source_name
@@ -220,7 +222,8 @@ module Pray
220
222
  case source.kind
221
223
  when "path"
222
224
  directory = LocalPrayer.path_source_package_directory(source_name, declaration.name)
223
- return [File.join(project_root, source.url, directory), nil]
225
+ relative = File.join(source.url, directory)
226
+ return [SourcePath.declared_directory(project_root, relative, "package directory"), nil]
224
227
  when "registry", "static index"
225
228
  resolved = Registry.resolve_registry_package_root(
226
229
  project_root,
@@ -264,7 +267,8 @@ module Pray
264
267
 
265
268
  raise Error.unsupported("remote sources are not implemented yet") if declaration.git || declaration.oci
266
269
 
267
- [File.join(project_root, declaration.name.tr("/", "-")), nil]
270
+ slug = declaration.name.tr("/", "-")
271
+ [SourcePath.declared_directory(project_root, slug, "package directory"), nil]
268
272
  end
269
273
 
270
274
  def resolve_package_root(project_root, sources, git_sources, user_config, declaration, lockfile = nil)
@@ -272,9 +276,12 @@ module Pray
272
276
  end
273
277
 
274
278
  def find_prayspec_file(root)
275
- files = Dir.children(root).filter_map do |entry|
279
+ files = []
280
+ Dir.each_child(root) do |entry|
281
+ next unless File.extname(entry) == ".prayspec"
282
+
276
283
  path = File.join(root, entry)
277
- (File.file?(path) && File.extname(entry) == ".prayspec") ? path : nil
284
+ files << path if File.file?(path)
278
285
  end
279
286
  case files.length
280
287
  when 1 then files.first
@@ -296,15 +303,7 @@ module Pray
296
303
  end
297
304
 
298
305
  def load_package_file_bytes(root, spec)
299
- file_bytes = {}
300
- spec.files.each do |file|
301
- path = File.join(root, file)
302
- raise Error.integrity("package file missing: #{file}") unless File.exist?(path)
303
- raise Error.integrity("package file is a directory: #{file}") if File.directory?(path)
304
-
305
- file_bytes[file] = File.binread(path)
306
- end
307
- file_bytes
306
+ spec.files.to_h { |file| [file, SourcePath.read(root, file, "package file")] }
308
307
  end
309
308
 
310
309
  def build_skill_file_index(spec)
@@ -331,12 +330,7 @@ module Pray
331
330
  end
332
331
 
333
332
  def skill_relative_file(file, skill_prefix)
334
- return nil unless file.start_with?(skill_prefix)
335
-
336
- relative = file.delete_prefix(skill_prefix).delete_prefix("/")
337
- return nil if relative.empty? || file == skill_prefix
338
-
339
- relative
333
+ file.delete_prefix("#{skill_prefix.delete_suffix("/")}/") if file.start_with?("#{skill_prefix.delete_suffix("/")}/")
340
334
  end
341
335
  end
342
336
  end
@@ -11,6 +11,7 @@ module Pray
11
11
 
12
12
  def resolve_local_file(project_root, declaration)
13
13
  path = File.join(project_root, declaration.path)
14
+ SourcePath.reject_symlinks!(project_root, declaration.path, "local source")
14
15
  unless File.exist?(path)
15
16
  if declaration.optional
16
17
  return ResolvedLocalFile.new(
data/lib/pray/serve.rb CHANGED
@@ -118,7 +118,7 @@ module Pray
118
118
  return html_response("<h1>Pray distribution</h1>")
119
119
  end
120
120
 
121
- file_path = PathSafety.join_under_root(root, path.delete_prefix("/"))
121
+ file_path = public_distribution_path(root, path)
122
122
  return not_found unless file_path
123
123
  return not_found unless File.file?(file_path)
124
124
  return payload_too_large if File.size(file_path) > ResourceLimits::MAX_HTTP_RESPONSE_BYTES
@@ -129,6 +129,20 @@ module Pray
129
129
  ranged_response(status, content_type, ranged_body, content_range)
130
130
  end
131
131
 
132
+ def public_distribution_path(root, request_path)
133
+ relative = PathSafety.sanitize_relative_path(request_path)
134
+ exact = %w[v1/index.json v1/distribution.json v1/trust.json]
135
+ return nil unless exact.include?(relative) || relative.start_with?("v1/packages/", "v1/artifacts/")
136
+
137
+ file_path = PathSafety.join_under_root(root, relative)
138
+ return nil unless file_path
139
+
140
+ SourcePath.reject_symlinks!(root, relative, "distribution file")
141
+ file_path
142
+ rescue Error
143
+ nil
144
+ end
145
+
132
146
  def content_type_for(path)
133
147
  case File.extname(path)
134
148
  when ".json" then "application/json"
@@ -51,6 +51,7 @@ module Pray
51
51
 
52
52
  def package_response(root, path)
53
53
  name = path.delete_prefix("/v1/sync/package/")
54
+ PathSafety.validate_registry_cache_identity!(name, "metadata")
54
55
  metadata_path = Publish.registry_metadata_path(root, name)
55
56
  return Serve.not_found unless File.file?(metadata_path)
56
57
 
@@ -61,6 +62,8 @@ module Pray
61
62
  "versions" => metadata.versions.map { |version| transport_version(version) }
62
63
  }
63
64
  Serve.ok_response("application/json", JSON.pretty_generate(body))
65
+ rescue Error
66
+ Serve.not_found
64
67
  end
65
68
 
66
69
  def append_confession(root, body)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Pray
6
+ module SourcePath
7
+ module_function
8
+
9
+ def declared_directory(root, relative, label)
10
+ reject_symlinks!(root, relative, label)
11
+ File.expand_path(relative, root)
12
+ end
13
+
14
+ def reject_symlinks!(root, relative, label)
15
+ current = Pathname(relative).absolute? ? File::SEPARATOR : root
16
+ status = nil
17
+ Pathname(relative).each_filename do |component|
18
+ current = File.join(current, component)
19
+ status = File.lstat(current)
20
+ raise Error.integrity("#{label} uses a symbolic link: #{relative}") if status.symlink?
21
+ rescue Errno::ENOENT
22
+ return nil
23
+ end
24
+ status
25
+ end
26
+
27
+ def read(root, relative, label)
28
+ status = reject_symlinks!(root, relative, label)
29
+ raise Error.integrity("#{label} missing: #{relative}") unless status
30
+ raise Error.integrity("#{label} is a directory: #{relative}") if status.directory?
31
+
32
+ File.binread(File.join(root, relative))
33
+ end
34
+ end
35
+ end
@@ -7,6 +7,7 @@ module Pray
7
7
  def expand_statement_surface(statement)
8
8
  trimmed = statement.strip
9
9
  return [] if trimmed.empty?
10
+ return [normalize_keyword_call(trimmed)] unless trimmed.include?(";") || trimmed.include?("{")
10
11
 
11
12
  Literal.split_top_level(trimmed, ";").flat_map { |segment| expand_one_surface(segment) }
12
13
  end
@@ -15,7 +16,7 @@ module Pray
15
16
  trimmed = statement.strip
16
17
  return [] if trimmed.empty?
17
18
 
18
- braced = expand_brace_block(trimmed)
19
+ braced = expand_brace_block(trimmed) if trimmed.include?("{")
19
20
  return braced if braced
20
21
 
21
22
  [normalize_keyword_call(trimmed)]
@@ -71,6 +72,7 @@ module Pray
71
72
  def normalize_keyword_call(statement)
72
73
  spaced = normalize_spaced_block_opener(statement)
73
74
  return spaced if spaced
75
+ return statement unless statement.include?("(")
74
76
 
75
77
  keyword = leading_identifier(statement)
76
78
  return statement unless keyword
data/lib/pray/trust.rb CHANGED
@@ -110,10 +110,11 @@ module Pray
110
110
  end
111
111
 
112
112
  def prepare_source_host_keys(sources)
113
- policy = load_policy_or_default
114
- sources.each_with_object({}) do |source, host_keys|
115
- next unless source.kind == "pray_ssh"
113
+ ssh_sources = sources.select { |source| source.kind == "pray_ssh" }
114
+ return {} if ssh_sources.empty?
116
115
 
116
+ policy = load_policy_or_default
117
+ ssh_sources.each_with_object({}) do |source, host_keys|
117
118
  rule = best_rule(policy, source.url)
118
119
  fingerprint = rule.allowed_host_keys.first
119
120
  host_keys[source.name] = fingerprint if fingerprint
data/lib/pray/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pray
4
- VERSION = "1.22.0"
5
- GENERATED_BY = "pray 1.22.0"
4
+ VERSION = "1.23.0"
5
+ GENERATED_BY = "pray 1.23.0"
6
6
  end
data/lib/pray.rb CHANGED
@@ -26,6 +26,7 @@ require_relative "pray/dotenv"
26
26
  require_relative "pray/project_context"
27
27
  require_relative "pray/environment"
28
28
  require_relative "pray/resolve_context"
29
+ require_relative "pray/source_path"
29
30
  require_relative "pray/resolve_source"
30
31
  require_relative "pray/resolve_exports"
31
32
  require_relative "pray/dependency_graph"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pray-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.22.0
4
+ version: 1.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -182,6 +182,7 @@ files:
182
182
  - lib/pray/render_layout.rb
183
183
  - lib/pray/render_managed.rb
184
184
  - lib/pray/render_patch.rb
185
+ - lib/pray/render_project.rb
185
186
  - lib/pray/resolve.rb
186
187
  - lib/pray/resolve_context.rb
187
188
  - lib/pray/resolve_deps.rb
@@ -194,6 +195,7 @@ files:
194
195
  - lib/pray/serve_federation.rb
195
196
  - lib/pray/serve_range.rb
196
197
  - lib/pray/session.rb
198
+ - lib/pray/source_path.rb
197
199
  - lib/pray/ssh_agent.rb
198
200
  - lib/pray/statement_surface.rb
199
201
  - lib/pray/substitute.rb