pray-cli 1.9.2 → 1.11.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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/pray/cache_clean.rb +76 -0
- data/lib/pray/cli/commands/meta.rb +9 -4
- data/lib/pray/cli/commands/workflow.rb +1 -1
- data/lib/pray/cli/help.rb +1 -1
- data/lib/pray/cli/helpers.rb +2 -1
- data/lib/pray/cli/parse.rb +19 -1
- data/lib/pray/cli.rb +2 -1
- data/lib/pray/compose_dest.rb +59 -0
- data/lib/pray/format_serialize.rb +6 -1
- data/lib/pray/lockfile.rb +32 -6
- data/lib/pray/lockfile_serialize.rb +15 -0
- data/lib/pray/manifest.rb +11 -10
- data/lib/pray/manifest_json.rb +4 -4
- data/lib/pray/manifest_parser.rb +1 -0
- data/lib/pray/manifest_parser_blocks.rb +5 -2
- data/lib/pray/manifest_parser_helpers.rb +34 -5
- data/lib/pray/materialize.rb +5 -3
- data/lib/pray/package_spec.rb +19 -0
- data/lib/pray/path_safety.rb +29 -0
- data/lib/pray/plan.rb +5 -11
- data/lib/pray/registry.rb +6 -13
- data/lib/pray/render.rb +43 -65
- data/lib/pray/render_content.rb +36 -0
- data/lib/pray/render_dest.rb +215 -0
- data/lib/pray/render_layout.rb +21 -0
- data/lib/pray/render_patch.rb +107 -0
- data/lib/pray/resolve.rb +5 -48
- data/lib/pray/resolve_exports.rb +79 -0
- data/lib/pray/tar_validation.rb +4 -1
- data/lib/pray/verify.rb +2 -0
- data/lib/pray/verify_provisioned.rb +55 -0
- data/lib/pray/version.rb +2 -2
- data/lib/pray.rb +6 -0
- metadata +9 -1
data/lib/pray/path_safety.rb
CHANGED
|
@@ -71,5 +71,34 @@ module Pray
|
|
|
71
71
|
|
|
72
72
|
path
|
|
73
73
|
end
|
|
74
|
+
|
|
75
|
+
def validate_destination_path!(value)
|
|
76
|
+
path = value.to_s.strip
|
|
77
|
+
if path.start_with?("~")
|
|
78
|
+
raise Error.manifest("project path must be repository-relative: #{value}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
validate_project_relative_path!(value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_registry_cache_identity!(package_name, version)
|
|
85
|
+
namespace, name, extra_segment = package_name.to_s.split("/", -1)
|
|
86
|
+
unless extra_segment.nil? && registry_cache_segment_safe?(namespace) && registry_cache_segment_safe?(name)
|
|
87
|
+
raise Error.integrity("invalid registry package name: #{package_name}")
|
|
88
|
+
end
|
|
89
|
+
unless registry_cache_segment_safe?(version)
|
|
90
|
+
raise Error.integrity("invalid registry package version: #{version}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
[namespace, name]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def registry_cache_segment_safe?(value)
|
|
97
|
+
value.is_a?(String) &&
|
|
98
|
+
!value.empty? &&
|
|
99
|
+
![".", ".."].include?(value) &&
|
|
100
|
+
!value.match?(%r{[/\\\0]})
|
|
101
|
+
end
|
|
102
|
+
private_class_method :registry_cache_segment_safe?
|
|
74
103
|
end
|
|
75
104
|
end
|
data/lib/pray/plan.rb
CHANGED
|
@@ -15,7 +15,9 @@ module Pray
|
|
|
15
15
|
package_lines: package_summary_lines(previous_lockfile, lockfile, project),
|
|
16
16
|
lockfile: lockfile_change_status(lockfile_path, lockfile),
|
|
17
17
|
targets: rendered.map { |target| target_change(project, target) },
|
|
18
|
-
provisioned: Render.planned_provisioned_files(project).map
|
|
18
|
+
provisioned: Render.planned_provisioned_files(project).map do |file|
|
|
19
|
+
provisioned_change(project, file, previous_lockfile)
|
|
20
|
+
end,
|
|
19
21
|
warnings: []
|
|
20
22
|
)
|
|
21
23
|
end
|
|
@@ -68,16 +70,8 @@ module Pray
|
|
|
68
70
|
[target.path, change]
|
|
69
71
|
end
|
|
70
72
|
|
|
71
|
-
def provisioned_change(project, file)
|
|
72
|
-
|
|
73
|
-
change = if !File.exist?(destination)
|
|
74
|
-
"write"
|
|
75
|
-
elsif FileUtils.compare_file(destination, file.source)
|
|
76
|
-
"unchanged"
|
|
77
|
-
else
|
|
78
|
-
"update"
|
|
79
|
-
end
|
|
80
|
-
[file.path, change]
|
|
73
|
+
def provisioned_change(project, file, previous_lockfile)
|
|
74
|
+
[file.path, RenderDest.destination_status(project, file, previous_lockfile).to_s]
|
|
81
75
|
end
|
|
82
76
|
end
|
|
83
77
|
end
|
data/lib/pray/registry.rb
CHANGED
|
@@ -47,8 +47,7 @@ module Pray
|
|
|
47
47
|
project_root,
|
|
48
48
|
source_url,
|
|
49
49
|
declaration.name,
|
|
50
|
-
selected.version
|
|
51
|
-
selected.artifact_hash
|
|
50
|
+
selected.version
|
|
52
51
|
)
|
|
53
52
|
|
|
54
53
|
if cache_ready?(cache_directory, selected)
|
|
@@ -91,8 +90,7 @@ module Pray
|
|
|
91
90
|
project_root,
|
|
92
91
|
source_key,
|
|
93
92
|
declaration.name,
|
|
94
|
-
selected.version
|
|
95
|
-
selected.artifact_hash
|
|
93
|
+
selected.version
|
|
96
94
|
)
|
|
97
95
|
|
|
98
96
|
if cache_ready?(cache_directory, selected)
|
|
@@ -238,15 +236,10 @@ module Pray
|
|
|
238
236
|
)
|
|
239
237
|
end
|
|
240
238
|
|
|
241
|
-
def registry_cache_directory(project_root, source_key, package_name, version
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
version,
|
|
246
|
-
artifact_hash || "no-artifact-hash"
|
|
247
|
-
].join(":")
|
|
248
|
-
digest = Hashing.sha256_hex(identifier)[0, 16]
|
|
249
|
-
File.join(project_root, ".pray", "cache", "registry", package_name.tr("/", "-"), version, digest)
|
|
239
|
+
def registry_cache_directory(project_root, source_key, package_name, version)
|
|
240
|
+
namespace, name = PathSafety.validate_registry_cache_identity!(package_name, version)
|
|
241
|
+
source_hash = Hashing.sha256_hex(source_key)[0, 16]
|
|
242
|
+
File.join(project_root, ".pray", "cache", "registry", namespace, name, version, source_hash)
|
|
250
243
|
end
|
|
251
244
|
|
|
252
245
|
def offline_package_error(package_name, version)
|
data/lib/pray/render.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "pathname"
|
|
5
|
+
require_relative "render_content"
|
|
5
6
|
|
|
6
7
|
module Pray
|
|
7
8
|
RenderedTarget = Struct.new(:path, :content, :managed_spans) do
|
|
@@ -23,33 +24,29 @@ module Pray
|
|
|
23
24
|
rendered
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
def write_rendered_targets(project, rendered)
|
|
27
|
+
def write_rendered_targets(project, rendered, previous_lockfile = nil)
|
|
27
28
|
rendered.each do |target|
|
|
28
|
-
PathSafety.
|
|
29
|
+
PathSafety.validate_destination_path!(target.path)
|
|
30
|
+
RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
29
31
|
path = File.join(project.project_root, target.path)
|
|
30
32
|
FileUtils.mkdir_p(File.dirname(path))
|
|
31
|
-
|
|
33
|
+
RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
34
|
+
RenderDest.write_rendered_content(path, target.path, target.content)
|
|
32
35
|
end
|
|
33
|
-
materialize_provisioned_exports(project)
|
|
36
|
+
materialize_provisioned_exports(project, previous_lockfile)
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
def materialize_provisioned_exports(project)
|
|
37
|
-
|
|
38
|
-
planned_provisioned_files(project).each do |file|
|
|
39
|
-
PathSafety.validate_project_relative_path!(file.path)
|
|
40
|
-
destination = File.join(project.project_root, file.path)
|
|
41
|
-
FileUtils.mkdir_p(File.dirname(destination))
|
|
42
|
-
write_provisioned_file(file.source, destination, symbols)
|
|
43
|
-
end
|
|
39
|
+
def materialize_provisioned_exports(project, previous_lockfile = nil)
|
|
40
|
+
RenderDest.materialize(project, previous_lockfile)
|
|
44
41
|
end
|
|
45
42
|
|
|
46
|
-
def
|
|
43
|
+
def expected_provisioned_bytes(source, symbols)
|
|
47
44
|
bytes = File.binread(source)
|
|
48
|
-
text = bytes.force_encoding(Encoding::UTF_8)
|
|
45
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
49
46
|
if text.valid_encoding?
|
|
50
|
-
|
|
47
|
+
Substitute.substitute_pray_symbols(text, symbols)
|
|
51
48
|
else
|
|
52
|
-
|
|
49
|
+
bytes
|
|
53
50
|
end
|
|
54
51
|
end
|
|
55
52
|
|
|
@@ -71,9 +68,10 @@ module Pray
|
|
|
71
68
|
planned.sort_by(&:path).uniq(&:path)
|
|
72
69
|
end
|
|
73
70
|
|
|
74
|
-
PlannedProvisionedFile = Struct.new(:path, :source)
|
|
71
|
+
PlannedProvisionedFile = Struct.new(:path, :source, :package, :export, keyword_init: true)
|
|
75
72
|
|
|
76
73
|
def render_target(project, target, output)
|
|
74
|
+
ComposeDest.ensure_html_comment_dest!(output)
|
|
77
75
|
if target.scoped && target.mode == "compose"
|
|
78
76
|
return render_scoped_compose(project, target, output)
|
|
79
77
|
end
|
|
@@ -82,7 +80,7 @@ module Pray
|
|
|
82
80
|
|
|
83
81
|
def render_scoped_compose(project, target, output)
|
|
84
82
|
builder = ContentBuilder.new
|
|
85
|
-
append_header(builder, project, output)
|
|
83
|
+
append_header(builder, project, target, output)
|
|
86
84
|
symbols = project.manifest.symbols || {}
|
|
87
85
|
managed_spans = []
|
|
88
86
|
|
|
@@ -99,7 +97,7 @@ module Pray
|
|
|
99
97
|
|
|
100
98
|
def render_legacy_compose(project, target, output)
|
|
101
99
|
builder = ContentBuilder.new
|
|
102
|
-
append_header(builder, project, output)
|
|
100
|
+
append_header(builder, project, target, output)
|
|
103
101
|
|
|
104
102
|
unbound_locals = project.local_files.select do |local|
|
|
105
103
|
declaration = project.manifest.local.find { |entry| entry.path == local.manifest_path }
|
|
@@ -137,16 +135,11 @@ module Pray
|
|
|
137
135
|
RenderedTarget.new(path: output, content: builder.finish, managed_spans: managed_spans)
|
|
138
136
|
end
|
|
139
137
|
|
|
140
|
-
def append_header(builder, project, output)
|
|
141
|
-
|
|
138
|
+
def append_header(builder, project, target, output)
|
|
139
|
+
text = ComposeDest.header_text(target, output, project.manifest.render.header)
|
|
140
|
+
return unless text
|
|
142
141
|
|
|
143
|
-
|
|
144
|
-
builder.append_line("<!-- pray:0 ignore-comments -->")
|
|
145
|
-
builder.append_empty_line
|
|
146
|
-
builder.append_line("# Agent context")
|
|
147
|
-
builder.append_empty_line
|
|
148
|
-
builder.append_line("Do not edit managed blocks in `#{output_name}` or provisioned files under `.agents/`.")
|
|
149
|
-
builder.append_line("To change shared guidance, update `Prayfile` and run `pray install`.")
|
|
142
|
+
builder.append_body(text)
|
|
150
143
|
builder.append_empty_line
|
|
151
144
|
end
|
|
152
145
|
|
|
@@ -173,7 +166,10 @@ module Pray
|
|
|
173
166
|
|
|
174
167
|
def append_managed_export(builder, managed_spans, package, export, target, output, symbols)
|
|
175
168
|
body = package.export_bodies[export]
|
|
176
|
-
|
|
169
|
+
unless body
|
|
170
|
+
raise Error.integrity("compose cannot write binary export #{export}; use file: for unmarked bytes") if package.spec.exports[export]&.kind == "file"
|
|
171
|
+
raise Error.render("package #{package.declaration.name} is missing cached export #{export}")
|
|
172
|
+
end
|
|
177
173
|
|
|
178
174
|
body = Substitute.substitute_pray_symbols(body, symbols)
|
|
179
175
|
identifier = Hashing.marker_id("#{package.declaration.name}:#{export}:#{target.name}")
|
|
@@ -198,7 +194,7 @@ module Pray
|
|
|
198
194
|
|
|
199
195
|
def should_inline_export?(package, export_name)
|
|
200
196
|
export = package.spec.exports[export_name]
|
|
201
|
-
export.nil? || export.kind
|
|
197
|
+
export.nil? || %w[fragment file].include?(export.kind)
|
|
202
198
|
end
|
|
203
199
|
|
|
204
200
|
def collect_exact_file_bindings(project, planned)
|
|
@@ -215,7 +211,12 @@ module Pray
|
|
|
215
211
|
source = File.join(package.root, export.path)
|
|
216
212
|
raise Error.render("file export source missing: #{source}") unless File.file?(source)
|
|
217
213
|
|
|
218
|
-
planned << PlannedProvisionedFile.new(
|
|
214
|
+
planned << PlannedProvisionedFile.new(
|
|
215
|
+
path: destination,
|
|
216
|
+
source: source,
|
|
217
|
+
package: package.declaration.name,
|
|
218
|
+
export: export_name
|
|
219
|
+
)
|
|
219
220
|
matched = true
|
|
220
221
|
break
|
|
221
222
|
end
|
|
@@ -241,6 +242,8 @@ module Pray
|
|
|
241
242
|
skill_files,
|
|
242
243
|
[],
|
|
243
244
|
[],
|
|
245
|
+
package.declaration.name,
|
|
246
|
+
skill_name,
|
|
244
247
|
planned
|
|
245
248
|
)
|
|
246
249
|
end
|
|
@@ -274,6 +277,8 @@ module Pray
|
|
|
274
277
|
indexed_files,
|
|
275
278
|
export.only || [],
|
|
276
279
|
export.except || [],
|
|
280
|
+
package.declaration.name,
|
|
281
|
+
export_name,
|
|
277
282
|
planned
|
|
278
283
|
)
|
|
279
284
|
when "file"
|
|
@@ -285,7 +290,9 @@ module Pray
|
|
|
285
290
|
destination = File.join(destination_root, export_name, File.basename(source))
|
|
286
291
|
planned << PlannedProvisionedFile.new(
|
|
287
292
|
path: relative_project_path(project, destination),
|
|
288
|
-
source: source
|
|
293
|
+
source: source,
|
|
294
|
+
package: package.declaration.name,
|
|
295
|
+
export: export_name
|
|
289
296
|
)
|
|
290
297
|
end
|
|
291
298
|
end
|
|
@@ -295,7 +302,7 @@ module Pray
|
|
|
295
302
|
File.basename(export_path.delete_suffix("/")).empty? ? export_name : File.basename(export_path.delete_suffix("/"))
|
|
296
303
|
end
|
|
297
304
|
|
|
298
|
-
def collect_tree_files(project, source_root, destination_root, relative_files, only, except, planned)
|
|
305
|
+
def collect_tree_files(project, source_root, destination_root, relative_files, only, except, package_name, export_name, planned)
|
|
299
306
|
raise Error.render("folder source directory missing: #{source_root}") unless File.directory?(source_root)
|
|
300
307
|
raise Error.render("no files listed in package manifest for #{source_root}") if relative_files.empty?
|
|
301
308
|
|
|
@@ -310,7 +317,9 @@ module Pray
|
|
|
310
317
|
destination = File.join(destination_root, relative)
|
|
311
318
|
planned << PlannedProvisionedFile.new(
|
|
312
319
|
path: relative_project_path(project, destination),
|
|
313
|
-
source: source
|
|
320
|
+
source: source,
|
|
321
|
+
package: package_name,
|
|
322
|
+
export: export_name
|
|
314
323
|
)
|
|
315
324
|
matched = true
|
|
316
325
|
end
|
|
@@ -325,36 +334,5 @@ module Pray
|
|
|
325
334
|
rescue ArgumentError
|
|
326
335
|
absolute
|
|
327
336
|
end
|
|
328
|
-
|
|
329
|
-
class ContentBuilder
|
|
330
|
-
def initialize
|
|
331
|
-
@content = +""
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
def next_line_number
|
|
335
|
-
@content.count("\n") + 1
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
def append_line(line)
|
|
339
|
-
@content << line << "\n"
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
def append_empty_line
|
|
343
|
-
@content << "\n"
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def append_body(body)
|
|
347
|
-
trimmed = body.sub(/\n+\z/, "")
|
|
348
|
-
return if trimmed.empty?
|
|
349
|
-
|
|
350
|
-
trimmed.each_line(chomp: true) { |line| append_line(line) }
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def finish
|
|
354
|
-
@content.sub(/\n\n+\z/, "\n")
|
|
355
|
-
@content << "\n" unless @content.end_with?("\n")
|
|
356
|
-
@content
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
337
|
end
|
|
360
338
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module Render
|
|
5
|
+
class ContentBuilder
|
|
6
|
+
def initialize
|
|
7
|
+
@content = +""
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def next_line_number
|
|
11
|
+
@content.count("\n") + 1
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def append_line(line)
|
|
15
|
+
@content << line << "\n"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def append_empty_line
|
|
19
|
+
@content << "\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def append_body(body)
|
|
23
|
+
trimmed = body.sub(/\n+\z/, "")
|
|
24
|
+
return if trimmed.empty?
|
|
25
|
+
|
|
26
|
+
trimmed.each_line(chomp: true) { |line| append_line(line) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def finish
|
|
30
|
+
@content.sub(/\n\n+\z/, "\n")
|
|
31
|
+
@content << "\n" unless @content.end_with?("\n")
|
|
32
|
+
@content
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Pray
|
|
7
|
+
module RenderDest
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def materialize(project, previous_lockfile = nil)
|
|
11
|
+
planned = Render.planned_provisioned_files(project)
|
|
12
|
+
previous = previous_map(previous_lockfile)
|
|
13
|
+
planned_paths = planned.map(&:path).to_h { |path| [path, true] }
|
|
14
|
+
planned.each { |file| write_leaf(project, file, previous) }
|
|
15
|
+
prune_dropped(project, previous_lockfile, planned_paths) if previous_lockfile
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def provisioned_records(project)
|
|
19
|
+
symbols = project.manifest.symbols || {}
|
|
20
|
+
Render.planned_provisioned_files(project).map do |file|
|
|
21
|
+
expected = Render.expected_provisioned_bytes(file.source, symbols)
|
|
22
|
+
ProvisionedFileRecord.new(
|
|
23
|
+
path: file.path.to_s.tr("\\", "/"),
|
|
24
|
+
content_hash: Hashing.sha256_prefixed(expected.b),
|
|
25
|
+
package: file.package,
|
|
26
|
+
export: file.export
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fail_if_symlink!(path, display)
|
|
32
|
+
return unless File.symlink?(path)
|
|
33
|
+
|
|
34
|
+
raise Error.render("refusing to write `#{display}` because it is a symbolic link")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def previous_map(lockfile)
|
|
38
|
+
return {} unless lockfile
|
|
39
|
+
|
|
40
|
+
Array(lockfile.provisioned).to_h { |record| [record.path, record] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def destination_status(project, file, previous_lockfile = nil)
|
|
44
|
+
PathSafety.validate_destination_path!(file.path)
|
|
45
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
46
|
+
destination = File.join(project.project_root, file.path)
|
|
47
|
+
expected = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
|
|
48
|
+
record = previous_map(previous_lockfile)[file.path.to_s.tr("\\", "/")]
|
|
49
|
+
classify_destination(destination, file.path, expected, record)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write_leaf(project, file, previous)
|
|
53
|
+
PathSafety.validate_destination_path!(file.path)
|
|
54
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
55
|
+
destination = File.join(project.project_root, file.path)
|
|
56
|
+
expected = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
|
|
57
|
+
record = previous[file.path.to_s.tr("\\", "/")]
|
|
58
|
+
status = classify_destination(destination, file.path, expected, record)
|
|
59
|
+
if status == :write
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
61
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
62
|
+
return create_bytes(destination, file.path, expected)
|
|
63
|
+
end
|
|
64
|
+
return if status == :unchanged
|
|
65
|
+
|
|
66
|
+
unless record
|
|
67
|
+
raise Error.render("missing lock ownership for `#{file.path}`")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
71
|
+
update_bytes(destination, file.path, expected, record.content_hash)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def classify_destination(destination, display, expected, record)
|
|
75
|
+
kind = destination_kind(destination)
|
|
76
|
+
return :write if kind == :missing
|
|
77
|
+
|
|
78
|
+
fail_if_symlink!(destination, display)
|
|
79
|
+
unless kind == :regular
|
|
80
|
+
raise Error.render("refusing to write `#{display}`; destination is not a regular file")
|
|
81
|
+
end
|
|
82
|
+
on_disk = read_regular_bytes(destination, display)
|
|
83
|
+
return :unchanged if on_disk.b == expected.b
|
|
84
|
+
return :update if record && Hashing.sha256_prefixed(on_disk) == record.content_hash
|
|
85
|
+
|
|
86
|
+
if record
|
|
87
|
+
raise Error.render("refusing to overwrite `#{display}`; it was provisioned and then edited")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise Error.render(
|
|
91
|
+
"refusing to overwrite `#{display}`; it already exists and is not the expected provisioned file"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prune_dropped(project, previous, planned_paths)
|
|
96
|
+
Array(previous.provisioned).each do |record|
|
|
97
|
+
PathSafety.validate_destination_path!(record.path)
|
|
98
|
+
next if planned_paths[record.path]
|
|
99
|
+
|
|
100
|
+
destination = File.join(project.project_root, record.path)
|
|
101
|
+
ensure_safe_destination_ancestors!(project.project_root, record.path, record.path)
|
|
102
|
+
next unless destination_kind(destination) == :regular
|
|
103
|
+
|
|
104
|
+
on_disk = read_regular_bytes(destination, record.path)
|
|
105
|
+
if Hashing.sha256_prefixed(on_disk) == record.content_hash
|
|
106
|
+
ensure_safe_destination_ancestors!(project.project_root, record.path, record.path)
|
|
107
|
+
File.delete(destination)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def layout_rendered_content(path, display, fresh)
|
|
113
|
+
kind = destination_kind(path)
|
|
114
|
+
return fresh if kind == :missing
|
|
115
|
+
|
|
116
|
+
fail_if_symlink!(path, display)
|
|
117
|
+
unless kind == :regular
|
|
118
|
+
raise Error.render("refusing to write `#{display}`; destination is not a regular file")
|
|
119
|
+
end
|
|
120
|
+
RenderPatch.patch_rendered_content(decode_utf8(read_regular_bytes(path, display), display), fresh)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def write_rendered_content(path, display, fresh)
|
|
124
|
+
return create_bytes(path, display, fresh) if destination_kind(path) == :missing
|
|
125
|
+
|
|
126
|
+
open_regular(path, display, File::RDWR) do |file|
|
|
127
|
+
existing = decode_utf8(file.read, display)
|
|
128
|
+
content = RenderPatch.patch_rendered_content(existing, fresh)
|
|
129
|
+
file.rewind
|
|
130
|
+
file.truncate(0)
|
|
131
|
+
file.write(content)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def ensure_safe_destination_ancestors!(project_root, relative_path, display)
|
|
136
|
+
parent = File.dirname(relative_path.to_s.tr("\\", "/"))
|
|
137
|
+
return if parent == "."
|
|
138
|
+
|
|
139
|
+
current = project_root
|
|
140
|
+
parent.split("/").each do |component|
|
|
141
|
+
next if component.empty? || component == "."
|
|
142
|
+
|
|
143
|
+
current = File.join(current, component)
|
|
144
|
+
metadata = File.lstat(current)
|
|
145
|
+
if metadata.symlink?
|
|
146
|
+
raise Error.render(
|
|
147
|
+
"refusing to write `#{display}` because a destination parent is a symbolic link"
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
unless metadata.directory?
|
|
151
|
+
raise Error.render(
|
|
152
|
+
"refusing to write `#{display}`; a destination parent is not a directory"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
rescue Errno::ENOENT
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def create_bytes(path, display, bytes)
|
|
161
|
+
open_path(path, display, File::WRONLY | File::CREAT | File::EXCL) do |file|
|
|
162
|
+
file.write(bytes)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def update_bytes(path, display, bytes, authorized_hash)
|
|
167
|
+
open_regular(path, display, File::RDWR) do |file|
|
|
168
|
+
on_disk = file.read
|
|
169
|
+
return if on_disk.b == bytes.b
|
|
170
|
+
|
|
171
|
+
unless Hashing.sha256_prefixed(on_disk) == authorized_hash
|
|
172
|
+
raise Error.render("refusing to overwrite `#{display}`; it was provisioned and then edited")
|
|
173
|
+
end
|
|
174
|
+
file.rewind
|
|
175
|
+
file.truncate(0)
|
|
176
|
+
file.write(bytes)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def read_regular_bytes(path, display)
|
|
181
|
+
open_regular(path, display, File::RDONLY) { |file| file.read }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def decode_utf8(bytes, display)
|
|
185
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
186
|
+
return text if text.valid_encoding?
|
|
187
|
+
|
|
188
|
+
raise Error.render("rendered destination `#{display}` is not valid UTF-8")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def open_regular(path, display, flags)
|
|
192
|
+
open_path(path, display, flags) do |file|
|
|
193
|
+
unless file.stat.file?
|
|
194
|
+
raise Error.render("refusing to write `#{display}`; destination is not a regular file")
|
|
195
|
+
end
|
|
196
|
+
yield file
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def open_path(path, display, flags)
|
|
201
|
+
no_follow = File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0
|
|
202
|
+
File.open(path, flags | no_follow) { |file| yield file }
|
|
203
|
+
rescue Errno::ELOOP
|
|
204
|
+
raise Error.render("refusing to write `#{display}` because it is a symbolic link")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def destination_kind(path)
|
|
208
|
+
return :symlink if File.symlink?(path)
|
|
209
|
+
return :missing unless File.exist?(path)
|
|
210
|
+
return :regular if File.file?(path)
|
|
211
|
+
|
|
212
|
+
:other
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module Render
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def layout_rendered_targets(project, rendered)
|
|
8
|
+
rendered.map do |target|
|
|
9
|
+
PathSafety.validate_destination_path!(target.path)
|
|
10
|
+
RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
11
|
+
path = File.join(project.project_root, target.path)
|
|
12
|
+
content = RenderDest.layout_rendered_content(path, target.path, target.content)
|
|
13
|
+
RenderedTarget.new(
|
|
14
|
+
path: target.path,
|
|
15
|
+
content: content,
|
|
16
|
+
managed_spans: RenderPatch.relocate_managed_spans(content, target.managed_spans)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|