pray-cli 1.21.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/lib/pray/cli/commands/distribution.rb +34 -3
  4. data/lib/pray/cli/commands/packages.rb +9 -7
  5. data/lib/pray/cli/commands/update_latest.rb +11 -4
  6. data/lib/pray/cli/commands/workflow.rb +4 -2
  7. data/lib/pray/cli/help.rb +7 -0
  8. data/lib/pray/cli.rb +14 -0
  9. data/lib/pray/constraint.rb +1 -1
  10. data/lib/pray/derived_metadata.rb +194 -0
  11. data/lib/pray/destination.rb +28 -8
  12. data/lib/pray/git_cache.rb +46 -19
  13. data/lib/pray/git_legacy.rb +36 -0
  14. data/lib/pray/git_materialize.rb +204 -38
  15. data/lib/pray/git_store.rb +101 -14
  16. data/lib/pray/literal.rb +4 -5
  17. data/lib/pray/lockfile_serialize.rb +6 -1
  18. data/lib/pray/manifest_json.rb +3 -27
  19. data/lib/pray/manifest_parser.rb +25 -3
  20. data/lib/pray/manifest_parser_blocks.rb +7 -7
  21. data/lib/pray/package_spec.rb +6 -1
  22. data/lib/pray/package_spec_paths.rb +18 -0
  23. data/lib/pray/publish.rb +50 -6
  24. data/lib/pray/registry.rb +10 -7
  25. data/lib/pray/render.rb +26 -32
  26. data/lib/pray/render_content.rb +4 -4
  27. data/lib/pray/render_project.rb +22 -0
  28. data/lib/pray/resolve.rb +17 -23
  29. data/lib/pray/resolve_local.rb +1 -0
  30. data/lib/pray/serve.rb +15 -1
  31. data/lib/pray/serve_federation.rb +3 -0
  32. data/lib/pray/source_path.rb +35 -0
  33. data/lib/pray/statement_surface.rb +3 -1
  34. data/lib/pray/sync.rb +1 -4
  35. data/lib/pray/tar_validation.rb +2 -0
  36. data/lib/pray/terminal.rb +28 -0
  37. data/lib/pray/trust.rb +4 -3
  38. data/lib/pray/upstream_merge.rb +6 -2
  39. data/lib/pray/upstream_plan.rb +84 -0
  40. data/lib/pray/version.rb +2 -2
  41. data/lib/pray.rb +4 -0
  42. metadata +8 -2
@@ -5,7 +5,10 @@ require "open3"
5
5
  require_relative "error"
6
6
  require_relative "git_clone"
7
7
  require_relative "git_run"
8
+ require_relative "git_store"
8
9
  require_relative "path_safety"
10
+ require_relative "resource_limits"
11
+ require_relative "tar_validation"
9
12
 
10
13
  module Pray
11
14
  module GitMaterialize
@@ -14,56 +17,129 @@ module Pray
14
17
 
15
18
  module_function
16
19
 
17
- def read_local_artifact_bytes(source_root, artifact)
18
- if artifact.start_with?("file://")
19
- path = PathSafety.join_under_root(source_root, artifact.delete_prefix("file://"))
20
- raise Error.resolution("package artifact path escapes distribution root") unless path
21
-
22
- return File.binread(path)
23
- end
24
- if artifact.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
25
- raise Error.integrity("remote artifact path must be relative: #{artifact}")
20
+ def read_local_artifact_bytes(source_root, artifact, offline: false)
21
+ if artifact.include?("://") || artifact.start_with?("file:")
22
+ raise Error.integrity(
23
+ "local and git catalog artifacts must be relative paths under the distribution root, not URLs: #{artifact}"
24
+ )
26
25
  end
27
26
 
28
27
  path = PathSafety.join_under_root(source_root, artifact)
29
28
  raise Error.resolution("package artifact path escapes distribution root") unless path
30
29
 
31
- materialize_catalog_file(source_root, artifact) unless File.exist?(path)
30
+ materialize_catalog_file(source_root, artifact, offline: offline) unless File.exist?(path)
32
31
  raise Error.resolution("package artifact missing at #{path}") unless File.exist?(path)
33
32
 
34
33
  File.binread(path)
35
34
  end
36
35
 
37
- def materialize_catalog_tree(git_dir, dest, revision, subdir, refresh)
38
- return if !refresh && catalog_matches?(dest, git_dir, revision)
36
+ def materialize_catalog_tree(git_dir, dest, revision, subdir, refresh, offline: false)
37
+ require_revision_present!(git_dir, revision, offline: offline)
38
+ GitStore.with_git_cache_lock(dest) do
39
+ return if !refresh && catalog_matches?(dest, git_dir, revision)
39
40
 
40
- FileUtils.rm_rf(dest) if File.exist?(dest)
41
- FileUtils.mkdir_p(dest)
42
- unpacked = GitClone.catalog_sparse_cones(subdir).any? do |prefix|
43
- unpack_archive_prefix(git_dir, dest, revision, prefix)
41
+ staging = "#{dest}.partial"
42
+ FileUtils.rm_rf(staging)
43
+ FileUtils.mkdir_p(staging)
44
+ unpacked = GitClone.catalog_sparse_cones(subdir).any? do |prefix|
45
+ unpack_archive_prefix(git_dir, staging, revision, prefix, offline: offline)
46
+ end
47
+ unless unpacked
48
+ FileUtils.rm_rf(staging)
49
+ if offline
50
+ raise Error.resolution(
51
+ "git catalog at revision #{revision} is not materializable locally and offline mode is enabled"
52
+ )
53
+ end
54
+ raise Error.resolution("no pray distribution root in git source at revision #{revision}")
55
+ end
56
+
57
+ File.write(File.join(staging, REVISION_MARKER), revision)
58
+ File.write(File.join(staging, GIT_DIR_MARKER), git_dir)
59
+ publish_catalog_tree!(dest, staging)
44
60
  end
45
- raise Error.resolution("no pray distribution root in git source at revision #{revision}") unless unpacked
61
+ end
46
62
 
47
- File.write(File.join(dest, REVISION_MARKER), revision)
48
- File.write(File.join(dest, GIT_DIR_MARKER), git_dir)
63
+ def publish_catalog_tree!(dest, staging)
64
+ FileUtils.mkdir_p(File.dirname(dest))
65
+ if File.exist?(dest)
66
+ backup = "#{dest}.previous"
67
+ FileUtils.rm_rf(backup)
68
+ File.rename(dest, backup)
69
+ begin
70
+ File.rename(staging, dest)
71
+ rescue
72
+ File.rename(backup, dest) if File.exist?(backup)
73
+ FileUtils.rm_rf(staging)
74
+ raise
75
+ end
76
+ FileUtils.rm_rf(backup)
77
+ else
78
+ File.rename(staging, dest)
79
+ end
49
80
  end
81
+ private_class_method :publish_catalog_tree!
50
82
 
51
- def materialize_catalog_file(source_root, relative)
52
- return if File.file?(File.join(source_root, relative))
83
+ def materialize_catalog_file(source_root, relative, offline: false)
84
+ full_path = File.join(source_root, relative)
85
+ return if File.file?(full_path)
53
86
 
54
87
  markers = find_catalog_markers(source_root)
55
88
  return unless markers
56
89
 
57
- full_path = File.join(source_root, relative)
58
- repo_relative = full_path.delete_prefix(markers.fetch(:work_tree)).delete_prefix("/")
59
- GitRun.run_git(
60
- markers.fetch(:git_dir),
61
- "--work-tree",
62
- markers.fetch(:work_tree),
63
- "checkout",
64
- markers.fetch(:revision),
65
- "--",
66
- repo_relative
90
+ GitStore.with_git_cache_lock(markers.fetch(:catalog_root)) do
91
+ return if File.file?(full_path)
92
+
93
+ require_revision_present!(markers.fetch(:git_dir), markers.fetch(:revision), offline: offline)
94
+ repo_relative = full_path.delete_prefix(markers.fetch(:catalog_root)).delete_prefix("/")
95
+ object = "#{markers.fetch(:revision)}:#{repo_relative}"
96
+ require_blob_within_limit!(markers.fetch(:git_dir), object, offline: offline)
97
+ env = GitRun.git_env(offline_git_env(offline))
98
+ stdout, status = Open3.capture2(
99
+ env,
100
+ "git",
101
+ "-c",
102
+ "protocol.file.allow=always",
103
+ "-C",
104
+ markers.fetch(:git_dir),
105
+ "show",
106
+ object,
107
+ stdin_data: ""
108
+ )
109
+ unless status.success?
110
+ if offline
111
+ raise Error.resolution(
112
+ "git catalog blob #{repo_relative} at revision #{markers.fetch(:revision)} is not available locally and offline mode is enabled"
113
+ )
114
+ end
115
+ annotate_git_materialize_failure!(
116
+ markers.fetch(:git_dir),
117
+ markers.fetch(:revision),
118
+ "git show failed"
119
+ )
120
+ end
121
+ write_catalog_blob_atomic!(full_path, stdout)
122
+ end
123
+ end
124
+
125
+ def require_revision_present!(git_dir, revision, offline: false)
126
+ return if GitRun.try_run_git(git_dir, "cat-file", "-e", revision)
127
+
128
+ if offline
129
+ raise Error.resolution(
130
+ "git revision #{revision} is not available in #{git_dir} and offline mode is enabled (git_revision_unfetched)."
131
+ )
132
+ end
133
+ raise Error.resolution(
134
+ "git revision #{revision} is not available in #{git_dir} (git_revision_unfetched). " \
135
+ "Run `pray install --locked`, or clear the project git cache and shared bare then install again."
136
+ )
137
+ end
138
+
139
+ def annotate_git_materialize_failure!(git_dir, revision, detail)
140
+ raise Error.resolution(
141
+ "git catalog materialize failed for revision #{revision} in #{git_dir} (git_cache_stale): #{detail}. " \
142
+ "Run `pray install --locked`, or clear `.pray/cache/git` and the shared bare then install again."
67
143
  )
68
144
  end
69
145
 
@@ -73,6 +149,7 @@ module Pray
73
149
  return false unless File.file?(revision_path) && File.file?(git_dir_path)
74
150
  return false unless File.read(revision_path).strip == revision
75
151
  return false unless File.read(git_dir_path).strip == git_dir
152
+ return false unless GitRun.try_run_git(git_dir, "cat-file", "-e", revision)
76
153
 
77
154
  File.directory?(File.join(dest, "v1", "packages")) ||
78
155
  File.directory?(File.join(dest, "prayers", "v1", "packages")) ||
@@ -81,8 +158,10 @@ module Pray
81
158
  false
82
159
  end
83
160
 
84
- def unpack_archive_prefix(git_dir, dest, revision, prefix)
85
- env = GitRun.git_env("GIT_TERMINAL_PROMPT" => "0")
161
+ def unpack_archive_prefix(git_dir, dest, revision, prefix, offline: false)
162
+ return false unless tree_prefix_within_limits?(git_dir, revision, prefix, offline: offline)
163
+
164
+ env = GitRun.git_env(offline_git_env(offline).merge("GIT_TERMINAL_PROMPT" => "0"))
86
165
  stdout, _stderr, status = Open3.capture3(
87
166
  env,
88
167
  "git",
@@ -98,13 +177,78 @@ module Pray
98
177
  stdin_data: ""
99
178
  )
100
179
  return false unless status.success? && !stdout.empty?
180
+ if stdout.bytesize > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
181
+ raise Error.integrity(
182
+ "git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
183
+ )
184
+ end
101
185
 
102
- _ignored, extract_status = Open3.capture2(env, "tar", "-x", "-C", dest, stdin_data: stdout)
103
- raise Error.resolution("failed to unpack git catalog archive") unless extract_status.success?
104
-
186
+ TarValidation.extract!(stdout, dest)
105
187
  true
106
188
  end
107
189
 
190
+ def require_blob_within_limit!(git_dir, object, offline: false)
191
+ env = GitRun.git_env(offline_git_env(offline))
192
+ stdout, status = Open3.capture2(
193
+ env, "git", "-c", "protocol.file.allow=always", "-C", git_dir, "cat-file", "-s", object,
194
+ stdin_data: ""
195
+ )
196
+ return unless status.success?
197
+
198
+ size = stdout.strip.to_i
199
+ return if size <= ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
200
+
201
+ raise Error.integrity(
202
+ "git catalog blob exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes"
203
+ )
204
+ end
205
+
206
+ def tree_prefix_within_limits?(git_dir, revision, prefix, offline: false)
207
+ env = GitRun.git_env(offline_git_env(offline))
208
+ stdout, status = Open3.capture2(
209
+ env, "git", "-c", "protocol.file.allow=always", "-C", git_dir,
210
+ "ls-tree", "-r", "-l", revision, "--", prefix, stdin_data: ""
211
+ )
212
+ return false unless status.success? && !stdout.strip.empty?
213
+
214
+ entry_count = 0
215
+ total_bytes = 0
216
+ stdout.each_line do |line|
217
+ parts = line.split
218
+ next unless parts[1] == "blob"
219
+
220
+ size = parts[3].to_i
221
+ entry_count += 1
222
+ if entry_count > ResourceLimits::MAX_ARCHIVE_ENTRIES
223
+ raise Error.integrity(
224
+ "git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRIES} entries"
225
+ )
226
+ end
227
+ if size > ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES
228
+ raise Error.integrity(
229
+ "git catalog archive entry exceeds #{ResourceLimits::MAX_ARCHIVE_ENTRY_BYTES} bytes"
230
+ )
231
+ end
232
+ total_bytes += size
233
+ if total_bytes > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
234
+ raise Error.integrity(
235
+ "git catalog archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
236
+ )
237
+ end
238
+ end
239
+ entry_count.positive?
240
+ end
241
+
242
+ def write_catalog_blob_atomic!(full_path, bytes)
243
+ FileUtils.mkdir_p(File.dirname(full_path))
244
+ partial = "#{full_path}.partial"
245
+ File.binwrite(partial, bytes)
246
+ File.rename(partial, full_path)
247
+ rescue
248
+ FileUtils.rm_f(partial)
249
+ raise
250
+ end
251
+
108
252
  def find_catalog_markers(start)
109
253
  current = start
110
254
  8.times do
@@ -113,9 +257,9 @@ module Pray
113
257
  if File.file?(revision_path) && File.file?(git_dir_path)
114
258
  revision = File.read(revision_path).strip
115
259
  git_dir = File.read(git_dir_path).strip
116
- return if revision.empty? || !File.exist?(git_dir)
260
+ return unless acceptable_revision?(revision) && acceptable_shared_bare?(git_dir)
117
261
 
118
- return {git_dir: git_dir, revision: revision, work_tree: current}
262
+ return {git_dir: git_dir, revision: revision, catalog_root: current}
119
263
  end
120
264
  parent = File.dirname(current)
121
265
  return if parent == current
@@ -124,5 +268,27 @@ module Pray
124
268
  end
125
269
  nil
126
270
  end
271
+
272
+ def acceptable_revision?(revision)
273
+ return false if revision.empty? || revision.start_with?("-")
274
+ return false unless [40, 64].include?(revision.length)
275
+
276
+ revision.match?(/\A[0-9a-fA-F]+\z/)
277
+ end
278
+
279
+ def acceptable_shared_bare?(git_dir)
280
+ return false unless File.file?(File.join(git_dir, "HEAD"))
281
+ return false unless File.directory?(File.join(git_dir, "objects"))
282
+
283
+ root = GitStore.global_cache_root
284
+ return false unless root
285
+
286
+ expected = File.join(root, "git")
287
+ PathSafety.path_under_root?(expected, git_dir)
288
+ end
289
+
290
+ def offline_git_env(offline)
291
+ offline ? {"GIT_NO_LAZY_FETCH" => "1"} : {}
292
+ end
127
293
  end
128
294
  end
@@ -8,27 +8,28 @@ require_relative "git_clone"
8
8
 
9
9
  module Pray
10
10
  module GitStore
11
+ READY_MARKER = ".pray-ready"
12
+
11
13
  module_function
12
14
 
13
15
  def ensure_global_git_db(clone_url, pinned_revision:, refresh:, offline:, working_directory:)
14
16
  db = global_git_cache_directory(clone_url)
15
17
  raise Error.resolution("git object cache is not configured") unless db
16
18
 
17
- unless global_git_cache_ready?(db)
18
- raise Error.resolution(offline_git_source_uncached(clone_url)) if offline
19
+ with_git_cache_lock(db) do
20
+ unless global_git_cache_ready?(db)
21
+ raise Error.resolution(offline_git_source_uncached(clone_url)) if offline
19
22
 
20
- FileUtils.rm_rf(db) if File.exist?(db)
21
- FileUtils.mkdir_p(File.dirname(db))
22
- GitClone.clone_bare_git_db(working_directory, clone_url, db, quiet: false)
23
- ensure_git_remote_origin(db, clone_url)
24
- end
25
- fetch_origin_tip(db, clone_url) if refresh && !offline
26
- if pinned_revision
27
- ensure_revision_in_db(db, clone_url, pinned_revision, !offline)
28
- return [db, pinned_revision]
29
- end
23
+ install_bare_clone(working_directory, clone_url, db)
24
+ end
25
+ fetch_origin_tip(db, clone_url) if refresh && !offline
26
+ if pinned_revision
27
+ ensure_revision_in_db(db, clone_url, pinned_revision, !offline)
28
+ return [db, pinned_revision]
29
+ end
30
30
 
31
- [db, git_head_revision(db)]
31
+ [db, git_head_revision(db)]
32
+ end
32
33
  end
33
34
 
34
35
  def global_cache_root
@@ -45,13 +46,98 @@ module Pray
45
46
  end
46
47
 
47
48
  def global_git_cache_ready?(global_cache)
48
- File.file?(File.join(global_cache, "HEAD")) || File.directory?(File.join(global_cache, ".git"))
49
+ return false unless File.file?(File.join(global_cache, "HEAD"))
50
+ return false unless File.directory?(File.join(global_cache, "objects"))
51
+ return true if File.file?(File.join(global_cache, READY_MARKER))
52
+
53
+ begin
54
+ git_head_revision(global_cache)
55
+ true
56
+ rescue Error
57
+ false
58
+ end
59
+ end
60
+
61
+ def mark_git_cache_ready(global_cache)
62
+ File.write(File.join(global_cache, READY_MARKER), "1")
49
63
  end
50
64
 
51
65
  def offline_git_source_uncached(clone_url)
52
66
  "git source #{clone_url} is not cached locally and offline mode is enabled"
53
67
  end
54
68
 
69
+ def install_bare_clone(working_directory, clone_url, db)
70
+ FileUtils.mkdir_p(File.dirname(db))
71
+ staging = "#{db}.partial"
72
+ FileUtils.rm_rf(staging) if File.exist?(staging)
73
+ GitClone.clone_bare_git_db(working_directory, clone_url, staging, quiet: false)
74
+ ensure_git_remote_origin(staging, clone_url)
75
+ git_head_revision(staging)
76
+ mark_git_cache_ready(staging)
77
+ if File.exist?(db)
78
+ if global_git_cache_ready?(db)
79
+ FileUtils.rm_rf(staging)
80
+ return
81
+ end
82
+ FileUtils.rm_rf(db)
83
+ end
84
+ FileUtils.mv(staging, db)
85
+ end
86
+
87
+ LOCK_ATTEMPTS = 2_400
88
+ LOCK_RETRY_SECONDS = 0.025
89
+ STALE_LOCK_SECONDS = 600
90
+
91
+ def with_git_cache_lock(db)
92
+ lock_directory = "#{db}.lock.d"
93
+ FileUtils.mkdir_p(File.dirname(lock_directory))
94
+ acquired = false
95
+ LOCK_ATTEMPTS.times do
96
+ Dir.mkdir(lock_directory)
97
+ write_lock_owner!(lock_directory)
98
+ acquired = true
99
+ break
100
+ rescue Errno::EEXIST
101
+ clear_stale_lock!(lock_directory)
102
+ sleep LOCK_RETRY_SECONDS
103
+ end
104
+ unless acquired
105
+ raise Error.resolution(
106
+ "timed out waiting for git cache lock #{lock_directory}. " \
107
+ "If no other pray process is using that cache, remove the lock directory and retry."
108
+ )
109
+ end
110
+
111
+ begin
112
+ yield
113
+ ensure
114
+ FileUtils.rm_rf(lock_directory)
115
+ end
116
+ end
117
+
118
+ def write_lock_owner!(lock_directory)
119
+ File.write(File.join(lock_directory, "owner"), "#{Process.pid}\n#{Time.now.to_i}\n")
120
+ end
121
+
122
+ def clear_stale_lock!(lock_directory)
123
+ age = lock_age_seconds(lock_directory)
124
+ return if age < STALE_LOCK_SECONDS
125
+
126
+ FileUtils.rm_rf(lock_directory)
127
+ end
128
+
129
+ def lock_age_seconds(lock_directory)
130
+ owner = File.join(lock_directory, "owner")
131
+ if File.file?(owner)
132
+ stamp = File.read(owner).lines[1].to_s.strip.to_i
133
+ return Time.now.to_i - stamp if stamp.positive?
134
+ end
135
+ Time.now.to_i - File.mtime(lock_directory).to_i
136
+ rescue Errno::ENOENT
137
+ 0
138
+ end
139
+ private_class_method :write_lock_owner!, :clear_stale_lock!, :lock_age_seconds
140
+
55
141
  def ensure_revision_in_db(db, clone_url, revision, allow_fetch)
56
142
  return if GitRun.try_run_git(db, "cat-file", "-e", revision)
57
143
  unless allow_fetch
@@ -110,6 +196,7 @@ module Pray
110
196
  GitRun.run_git(db, "fetch", "--depth", "1", "origin")
111
197
  end
112
198
  GitRun.run_git(db, "update-ref", "HEAD", "FETCH_HEAD")
199
+ mark_git_cache_ready(db)
113
200
  end
114
201
 
115
202
  def fetch_unshallow(db)
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)
@@ -55,6 +55,7 @@ module Pray
55
55
  end
56
56
 
57
57
  def tree_hash_for_root(root)
58
+ validate_relative_paths!
58
59
  file_bytes = {}
59
60
  files.each do |file|
60
61
  path = File.join(root, file)
@@ -196,7 +197,11 @@ module Pray
196
197
  expect_start!
197
198
  spec = PackageSpec.new
198
199
  while (statement = next_statement)
199
- return spec.canonicalized if statement == "end"
200
+ if statement == "end"
201
+ package = spec.canonicalized
202
+ package.validate_relative_paths!
203
+ return package
204
+ end
200
205
 
201
206
  apply_statement(spec, statement)
202
207
  end