pray-cli 1.20.0 → 1.22.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.
@@ -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 = ENV.to_h.merge("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
data/lib/pray/git_run.rb CHANGED
@@ -5,8 +5,37 @@ require_relative "error"
5
5
 
6
6
  module Pray
7
7
  module GitRun
8
+ # Mirrors git's own sanitize_repo_env: `git rev-parse --local-env-vars` minus
9
+ # GIT_CONFIG_PARAMETERS and GIT_CONFIG_COUNT. Git exports these to hooks, so
10
+ # pray started from a hook would otherwise run every cache command against the
11
+ # host repository. The two config variables pass through, as they do when git
12
+ # enters a submodule, because callers such as CI jobs use them to hand git
13
+ # credentials or URL rewrites, and they cannot select a repository.
14
+ REPOSITORY_LOCAL_VARIABLES = %w[
15
+ GIT_ALTERNATE_OBJECT_DIRECTORIES
16
+ GIT_CONFIG
17
+ GIT_OBJECT_DIRECTORY
18
+ GIT_DIR
19
+ GIT_WORK_TREE
20
+ GIT_IMPLICIT_WORK_TREE
21
+ GIT_GRAFT_FILE
22
+ GIT_INDEX_FILE
23
+ GIT_NO_REPLACE_OBJECTS
24
+ GIT_REPLACE_REF_BASE
25
+ GIT_PREFIX
26
+ GIT_SHALLOW_FILE
27
+ GIT_COMMON_DIR
28
+ ].freeze
29
+
8
30
  module_function
9
31
 
32
+ # Process.spawn merges this hash into the inherited environment, so a nil
33
+ # value is what removes a variable. Credential, SSH, proxy and global config
34
+ # variables still pass through.
35
+ def git_env(extra = {})
36
+ REPOSITORY_LOCAL_VARIABLES.to_h { |name| [name, nil] }.merge(extra)
37
+ end
38
+
10
39
  def run_git(cwd, *arguments)
11
40
  output, status = capture_git(cwd, *arguments)
12
41
  return if status.success?
@@ -20,7 +49,7 @@ module Pray
20
49
  end
21
50
 
22
51
  def capture_git(cwd, *arguments)
23
- env = ENV.to_h.merge("GIT_TERMINAL_PROMPT" => "0")
52
+ env = git_env("GIT_TERMINAL_PROMPT" => "0")
24
53
  Open3.capture2e(
25
54
  env,
26
55
  "git",
@@ -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
@@ -73,6 +159,7 @@ module Pray
73
159
  end
74
160
 
75
161
  def ensure_git_remote_origin(repository, clone_url)
162
+ require_own_git_dir(repository)
76
163
  if GitRun.try_run_git(repository, "remote", "get-url", "origin")
77
164
  GitRun.run_git(repository, "remote", "set-url", "origin", clone_url)
78
165
  else
@@ -80,12 +167,36 @@ module Pray
80
167
  end
81
168
  end
82
169
 
170
+ # Git searches parent directories when `repository` is not a valid git
171
+ # directory, so a damaged cache inside another checkout would otherwise
172
+ # rewrite that checkout's origin.
173
+ def require_own_git_dir(repository)
174
+ output, status = GitRun.capture_git(repository, "rev-parse", "--absolute-git-dir")
175
+ resolved = status.success? ? output.strip : ""
176
+ own = [repository, File.join(repository, ".git")].any? do |candidate|
177
+ real_path(candidate) && real_path(candidate) == real_path(resolved)
178
+ end
179
+ return if own
180
+
181
+ raise Error.resolution(
182
+ "git cache #{repository} is not a usable git repository (git resolved #{resolved.inspect}). " \
183
+ "Remove it and run the command again."
184
+ )
185
+ end
186
+
187
+ def real_path(path)
188
+ File.realpath(path)
189
+ rescue SystemCallError
190
+ nil
191
+ end
192
+
83
193
  def fetch_origin_tip(db, clone_url)
84
194
  ensure_git_remote_origin(db, clone_url)
85
195
  unless GitRun.try_run_git(db, "fetch", "--depth", "1", "--filter=blob:none", "origin")
86
196
  GitRun.run_git(db, "fetch", "--depth", "1", "origin")
87
197
  end
88
198
  GitRun.run_git(db, "update-ref", "HEAD", "FETCH_HEAD")
199
+ mark_git_cache_ready(db)
89
200
  end
90
201
 
91
202
  def fetch_unshallow(db)
@@ -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
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_safety"
4
+
5
+ module Pray
6
+ class PackageSpec
7
+ def validate_relative_paths!
8
+ files.each { |file| PathSafety.validate_archive_member_path!(file) }
9
+ exports.each_value do |export|
10
+ PathSafety.validate_archive_member_path!(export.path)
11
+ PathSafety.validate_archive_member_path!(export.default_path) if export.default_path
12
+ end
13
+ skills.each_value { |skill| PathSafety.validate_archive_member_path!(skill.path) }
14
+ templates.each_value { |template| PathSafety.validate_archive_member_path!(template.path) }
15
+ self
16
+ end
17
+ end
18
+ end