pray-cli 1.11.0 → 1.12.1
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 +14 -0
- data/lib/pray/cli/commands/packages.rb +2 -2
- data/lib/pray/cli/commands/workflow.rb +9 -1
- data/lib/pray/cli/help.rb +2 -2
- data/lib/pray/cli.rb +5 -1
- data/lib/pray/config.rb +3 -3
- data/lib/pray/git_refresh.rb +69 -0
- data/lib/pray/invocation.rb +6 -2
- data/lib/pray/lockfile.rb +5 -5
- data/lib/pray/materialize.rb +17 -6
- data/lib/pray/plan.rb +7 -9
- data/lib/pray/render.rb +5 -14
- data/lib/pray/render_dest.rb +76 -9
- data/lib/pray/resolve.rb +29 -12
- data/lib/pray/transaction/journal.rb +170 -0
- data/lib/pray/transaction/owner.rb +87 -0
- data/lib/pray/transaction/records.rb +65 -0
- data/lib/pray/transaction.rb +61 -0
- data/lib/pray/trust.rb +3 -3
- data/lib/pray/trust_feed.rb +2 -2
- data/lib/pray/verify.rb +2 -2
- data/lib/pray/verify_provisioned.rb +12 -4
- data/lib/pray/version.rb +2 -2
- data/lib/pray.rb +2 -0
- data/pray-cli.gemspec +1 -1
- metadata +15 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58da4ada7990f04bc2acf64b33bca633828a586f0918e1e3ff00cc50ddaeb7c5
|
|
4
|
+
data.tar.gz: ffc0855f9f9dc6466fe1260905b23d9ab74a5d96905562f8307f472dde851849
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 20cab97de54d753c4f7191f6a68469bbc7f4801e16999e7ca8ee13c0e1c5c27e53b8e0c436695a6f457cb169e7f4389d41407f9e56752cf7fed33c329b5fa94b
|
|
7
|
+
data.tar.gz: 109496e4410f4bc43f63af51a73ec3439f87348a5c1e94dbed712799416d8e47286360f97bb50e37aaad096f8c0957fc3e2a9a26840167fefe7ea1d6dbb66abd
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.12.1 (2026-09-08)
|
|
6
|
+
|
|
7
|
+
- Refresh a locked git catalog during `pray install` when a newly declared package is missing from the pinned revision, and name that revision with `pray update` if it is still missing.
|
|
8
|
+
|
|
9
|
+
## 1.12.0 (2026-09-07)
|
|
10
|
+
|
|
11
|
+
- Speed up planning for large package trees and reuse the resolved lock during installation.
|
|
12
|
+
- Report duplicate lockfile fields as parsing errors with lockfile context.
|
|
13
|
+
- Restore compose files, provisioned destinations, Prayfile, and lock after failed writes, and recover interrupted writes on the next install, plan, or verification on Unix.
|
|
14
|
+
- Preserve later local edits when recovery encounters changed files, and prevent cooperating Pray commands from writing the same project together.
|
|
15
|
+
- Limit destination reads to 32 MiB, saved transaction payload to 64 MiB across 10,000 writes, and grouped conflict details to 100 entries.
|
|
16
|
+
- Report conflicting file and tree destinations together before changing compose output, with steps that preserve local edits.
|
|
17
|
+
- Reject unsupported update options (`--latest`, `--major`, `--dry-run`, and `--json`) instead of silently ignoring them.
|
|
18
|
+
|
|
5
19
|
## 1.11.0 (2026-09-04)
|
|
6
20
|
|
|
7
21
|
- Read the whole eight byte tar checksum field so `.praypkg` archives whose checksum is written as seven octal digits unpack instead of failing integrity.
|
|
@@ -15,7 +15,7 @@ module Pray
|
|
|
15
15
|
declaration = Pray.format_package_declaration(
|
|
16
16
|
ManifestPackage.new(name: name, constraint: constraint || "*", path: path)
|
|
17
17
|
)
|
|
18
|
-
|
|
18
|
+
Transaction.write_file(manifest_path_value, insert_manifest_statement(manifest_text, declaration))
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def remove_command(name)
|
|
@@ -28,7 +28,7 @@ module Pray
|
|
|
28
28
|
raise Error.manifest("package #{name} not found")
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Transaction.write_file(manifest_path_value, remove_manifest_statement(manifest_text, name))
|
|
32
32
|
install_command({locked: false, frozen: false, offline: false})
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -76,7 +76,7 @@ module Pray
|
|
|
76
76
|
)
|
|
77
77
|
rendered = Render.render_project(project)
|
|
78
78
|
lockfile = build_lockfile(project, rendered)
|
|
79
|
-
previous_lockfile =
|
|
79
|
+
previous_lockfile = project.previous_lockfile
|
|
80
80
|
preview = Plan.build_materialization_preview(
|
|
81
81
|
project, rendered, lockfile, lockfile_path, previous_lockfile
|
|
82
82
|
)
|
|
@@ -84,6 +84,14 @@ module Pray
|
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def update_command(arguments)
|
|
87
|
+
if arguments.any? { |argument| %w[--dry-run --json].include?(argument) }
|
|
88
|
+
raise Error.unsupported("this Ruby CLI does not support --dry-run or --json for update; use `pray plan` to preview the current Prayfile")
|
|
89
|
+
end
|
|
90
|
+
if arguments.any? { |argument| %w[--latest --major].include?(argument) }
|
|
91
|
+
raise Error.unsupported(
|
|
92
|
+
"to update beyond the current constraint, edit the package version in Prayfile, then run `pray update`"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
87
95
|
package = arguments.reject { |argument| argument.start_with?("--") }.first
|
|
88
96
|
offline = arguments.include?("--offline")
|
|
89
97
|
if package
|
data/lib/pray/cli/help.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Pray
|
|
|
16
16
|
PACKAGE_COMMANDS = [
|
|
17
17
|
"add <name> [constraint] [--path PATH] declare a package in Prayfile",
|
|
18
18
|
"remove <name> remove a package from Prayfile",
|
|
19
|
-
"update [package]
|
|
19
|
+
"update [package]",
|
|
20
20
|
"unlock <package> clear a locked package pin",
|
|
21
21
|
"vendor copy resolved packages locally",
|
|
22
22
|
"clean remove local cache and vendor trees"
|
|
@@ -101,7 +101,7 @@ module Pray
|
|
|
101
101
|
"update" => <<~TEXT.strip,
|
|
102
102
|
refresh package versions within constraints
|
|
103
103
|
|
|
104
|
-
Usage: pray update [package]
|
|
104
|
+
Usage: pray update [package]
|
|
105
105
|
TEXT
|
|
106
106
|
"plan" => <<~TEXT.strip,
|
|
107
107
|
preview install/apply changes
|
data/lib/pray/cli.rb
CHANGED
|
@@ -30,7 +30,11 @@ module Pray
|
|
|
30
30
|
return if maybe_print_help(arguments)
|
|
31
31
|
|
|
32
32
|
command = parse_command(arguments)
|
|
33
|
-
|
|
33
|
+
if %i[install apply update unlock add remove render plan verify drift].include?(command.first)
|
|
34
|
+
Transaction.run(Invocation.invocation_context.project_root) { dispatch(command) }
|
|
35
|
+
else
|
|
36
|
+
dispatch(command)
|
|
37
|
+
end
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def maybe_print_help(arguments)
|
data/lib/pray/config.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "perfect_toml"
|
|
4
4
|
|
|
5
5
|
module Pray
|
|
6
6
|
module Config
|
|
@@ -22,14 +22,14 @@ module Pray
|
|
|
22
22
|
path = user_config_path
|
|
23
23
|
return PrayConfig.new unless path && File.file?(path)
|
|
24
24
|
|
|
25
|
-
data =
|
|
25
|
+
data = PerfectTOML.parse(File.read(path, encoding: "UTF-8"))
|
|
26
26
|
PrayConfig.new(
|
|
27
27
|
local: PrayLocalConfig.new(
|
|
28
28
|
package: data.dig("local", "package") || {},
|
|
29
29
|
source: data.dig("local", "source") || {}
|
|
30
30
|
)
|
|
31
31
|
)
|
|
32
|
-
rescue
|
|
32
|
+
rescue PerfectTOML::ParseError => error
|
|
33
33
|
raise Error.parse("config", "#{path}: #{error.message}")
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module GitRefresh
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def resolution_may_benefit_from_git_source_refresh?(error)
|
|
8
|
+
error.category == :resolution && catalog_miss_message?(error.message)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def catalog_miss_message?(message)
|
|
12
|
+
message.include?("no registry version") ||
|
|
13
|
+
message.include?("not found in distribution") ||
|
|
14
|
+
message.include?("not found in git source") ||
|
|
15
|
+
message.include?("v1/packages/")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def annotate_missing_git_catalog(error, package_name:, source_name:, revision:)
|
|
19
|
+
return error unless error.category == :resolution
|
|
20
|
+
return error if revision.to_s.empty?
|
|
21
|
+
return error unless catalog_miss_message?(error.message)
|
|
22
|
+
return error if error.message.include?("not found in git source")
|
|
23
|
+
|
|
24
|
+
Error.resolution(
|
|
25
|
+
"package #{package_name} was not found in git source #{source_name} at revision #{revision}. " \
|
|
26
|
+
"Run `pray update` to advance the source pin."
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def annotate_failed_refresh(lockfile_path, error)
|
|
31
|
+
return error unless error.category == :resolution
|
|
32
|
+
return error unless catalog_miss_message?(error.message)
|
|
33
|
+
|
|
34
|
+
pins = locked_git_revision_guidance(lockfile_path)
|
|
35
|
+
return error if pins.nil?
|
|
36
|
+
|
|
37
|
+
package = package_name_from_catalog_miss(error.message) || "the declared package"
|
|
38
|
+
Error.resolution(
|
|
39
|
+
"package #{package} was not found in locked git source (#{pins}). " \
|
|
40
|
+
"Run `pray update` to advance the source pin."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def locked_git_revision_guidance(lockfile_path)
|
|
45
|
+
return unless File.exist?(lockfile_path)
|
|
46
|
+
|
|
47
|
+
lockfile = LockfileIO.read_lockfile(lockfile_path)
|
|
48
|
+
pins = lockfile.source.filter_map do |source|
|
|
49
|
+
next unless source.kind == "git" && source.revision
|
|
50
|
+
|
|
51
|
+
"#{source.name} at #{source.revision}"
|
|
52
|
+
end
|
|
53
|
+
pins.empty? ? nil : pins.join(", ")
|
|
54
|
+
rescue Error
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def package_name_from_catalog_miss(message)
|
|
59
|
+
rest = message.delete_prefix("package ")
|
|
60
|
+
return if rest == message
|
|
61
|
+
|
|
62
|
+
ending = rest.index(" was not found") || rest.index(" not found")
|
|
63
|
+
return unless ending
|
|
64
|
+
|
|
65
|
+
name = rest[0, ending].strip
|
|
66
|
+
name.empty? ? nil : name
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/pray/invocation.rb
CHANGED
|
@@ -49,10 +49,14 @@ module Pray
|
|
|
49
49
|
if allow_git_refresh_fallback &&
|
|
50
50
|
!options.offline &&
|
|
51
51
|
!options.refresh &&
|
|
52
|
-
|
|
52
|
+
GitRefresh.resolution_may_benefit_from_git_source_refresh?(error)
|
|
53
53
|
refreshed = options.dup
|
|
54
54
|
refreshed.refresh = true
|
|
55
|
-
|
|
55
|
+
begin
|
|
56
|
+
resolve_current_project(refreshed)
|
|
57
|
+
rescue Error => retry_error
|
|
58
|
+
raise GitRefresh.annotate_failed_refresh(lockfile_path, retry_error)
|
|
59
|
+
end
|
|
56
60
|
else
|
|
57
61
|
raise
|
|
58
62
|
end
|
data/lib/pray/lockfile.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "perfect_toml"
|
|
4
4
|
|
|
5
5
|
require_relative "lockfile_serialize"
|
|
6
6
|
|
|
@@ -125,9 +125,9 @@ module Pray
|
|
|
125
125
|
end
|
|
126
126
|
|
|
127
127
|
def parse_lockfile(text)
|
|
128
|
-
data =
|
|
128
|
+
data = PerfectTOML.parse(text)
|
|
129
129
|
from_hash(data)
|
|
130
|
-
rescue
|
|
130
|
+
rescue PerfectTOML::ParseError => error
|
|
131
131
|
raise Error.parse("lockfile", error.message)
|
|
132
132
|
end
|
|
133
133
|
|
|
@@ -144,7 +144,7 @@ module Pray
|
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
def write_lockfile(path, lockfile)
|
|
147
|
-
|
|
147
|
+
Transaction.write_file(path, serialize_lockfile(lockfile))
|
|
148
148
|
end
|
|
149
149
|
|
|
150
150
|
def write_lockfile_if_changed(path, lockfile)
|
|
@@ -153,7 +153,7 @@ module Pray
|
|
|
153
153
|
return
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
Transaction.write_file(path, serialized)
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
def lockfiles_equivalent?(left, right)
|
data/lib/pray/materialize.rb
CHANGED
|
@@ -4,7 +4,11 @@ module Pray
|
|
|
4
4
|
module Materialize
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
-
def materialize_project(
|
|
7
|
+
def materialize_project(**options)
|
|
8
|
+
Transaction.run(Invocation.invocation_context.project_root) { materialize_in_transaction(**options) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def materialize_in_transaction(
|
|
8
12
|
manifest_path: nil,
|
|
9
13
|
frozen: false,
|
|
10
14
|
locked: false,
|
|
@@ -30,17 +34,24 @@ module Pray
|
|
|
30
34
|
if allow_git_refresh_fallback &&
|
|
31
35
|
!offline &&
|
|
32
36
|
!refresh &&
|
|
33
|
-
|
|
37
|
+
GitRefresh.resolution_may_benefit_from_git_source_refresh?(error)
|
|
34
38
|
refreshed = options.dup
|
|
35
39
|
refreshed.refresh = true
|
|
36
|
-
|
|
40
|
+
begin
|
|
41
|
+
Resolve.resolve_project_in_context(manifest_path, project_root, refreshed)
|
|
42
|
+
rescue Error => retry_error
|
|
43
|
+
raise GitRefresh.annotate_failed_refresh(
|
|
44
|
+
default_lockfile_path(project_root),
|
|
45
|
+
retry_error
|
|
46
|
+
)
|
|
47
|
+
end
|
|
37
48
|
else
|
|
38
49
|
raise
|
|
39
50
|
end
|
|
40
51
|
end
|
|
41
52
|
rendered = Render.render_project(project)
|
|
42
53
|
lockfile_path = default_lockfile_path(project.project_root)
|
|
43
|
-
previous_lockfile =
|
|
54
|
+
previous_lockfile = project.previous_lockfile
|
|
44
55
|
next_lockfile = LockfileIO.build_lockfile(
|
|
45
56
|
project.manifest_hash,
|
|
46
57
|
project.environment,
|
|
@@ -58,7 +69,7 @@ module Pray
|
|
|
58
69
|
unless File.exist?(lockfile_path)
|
|
59
70
|
raise Error.verify("missing Prayfile.lock; run install first")
|
|
60
71
|
end
|
|
61
|
-
existing =
|
|
72
|
+
existing = previous_lockfile
|
|
62
73
|
unless Pray.lockfiles_equivalent?(existing, next_lockfile)
|
|
63
74
|
raise Error.verify("lockfile needs update; rerun install to refresh Prayfile.lock")
|
|
64
75
|
end
|
|
@@ -80,7 +91,7 @@ module Pray
|
|
|
80
91
|
end
|
|
81
92
|
|
|
82
93
|
if frozen
|
|
83
|
-
existing =
|
|
94
|
+
existing = previous_lockfile
|
|
84
95
|
if existing
|
|
85
96
|
rendered.each do |target|
|
|
86
97
|
output_path = File.join(project.project_root, target.path)
|
data/lib/pray/plan.rb
CHANGED
|
@@ -10,14 +10,13 @@ module Pray
|
|
|
10
10
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
def build_materialization_preview(project, rendered, lockfile,
|
|
13
|
+
def build_materialization_preview(project, rendered, lockfile, _lockfile_path, previous_lockfile)
|
|
14
|
+
destinations = RenderDest.validate_destinations!(project, previous_lockfile)
|
|
14
15
|
MaterializationPreview.new(
|
|
15
16
|
package_lines: package_summary_lines(previous_lockfile, lockfile, project),
|
|
16
|
-
lockfile: lockfile_change_status(
|
|
17
|
+
lockfile: lockfile_change_status(previous_lockfile, lockfile),
|
|
17
18
|
targets: rendered.map { |target| target_change(project, target) },
|
|
18
|
-
provisioned:
|
|
19
|
-
provisioned_change(project, file, previous_lockfile)
|
|
20
|
-
end,
|
|
19
|
+
provisioned: destinations.map { |file, status| [file.path, status.to_s] },
|
|
21
20
|
warnings: []
|
|
22
21
|
)
|
|
23
22
|
end
|
|
@@ -51,10 +50,9 @@ module Pray
|
|
|
51
50
|
end
|
|
52
51
|
end
|
|
53
52
|
|
|
54
|
-
def lockfile_change_status(
|
|
55
|
-
return "create" unless
|
|
53
|
+
def lockfile_change_status(existing, lockfile)
|
|
54
|
+
return "create" unless existing
|
|
56
55
|
|
|
57
|
-
existing = Pray.read_lockfile(lockfile_path)
|
|
58
56
|
Pray.lockfiles_equivalent?(lockfile, existing) ? "unchanged" : "update"
|
|
59
57
|
end
|
|
60
58
|
|
|
@@ -62,7 +60,7 @@ module Pray
|
|
|
62
60
|
path = File.join(project.project_root, target.path)
|
|
63
61
|
change = if !File.exist?(path)
|
|
64
62
|
"write"
|
|
65
|
-
elsif
|
|
63
|
+
elsif RenderDest.read_regular_bytes(path, target.path) == target.content.b
|
|
66
64
|
"unchanged"
|
|
67
65
|
else
|
|
68
66
|
"update"
|
data/lib/pray/render.rb
CHANGED
|
@@ -25,20 +25,10 @@ module Pray
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def write_rendered_targets(project, rendered, previous_lockfile = nil)
|
|
28
|
-
|
|
29
|
-
PathSafety.validate_destination_path!(target.path)
|
|
30
|
-
RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
31
|
-
path = File.join(project.project_root, target.path)
|
|
32
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
33
|
-
RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
34
|
-
RenderDest.write_rendered_content(path, target.path, target.content)
|
|
35
|
-
end
|
|
36
|
-
materialize_provisioned_exports(project, previous_lockfile)
|
|
28
|
+
Transaction.run(project.project_root) { RenderDest.write_rendered_targets(project, rendered, previous_lockfile) }
|
|
37
29
|
end
|
|
38
30
|
|
|
39
|
-
def materialize_provisioned_exports(project, previous_lockfile = nil)
|
|
40
|
-
RenderDest.materialize(project, previous_lockfile)
|
|
41
|
-
end
|
|
31
|
+
def materialize_provisioned_exports(project, previous_lockfile = nil) = RenderDest.materialize(project, previous_lockfile)
|
|
42
32
|
|
|
43
33
|
def expected_provisioned_bytes(source, symbols)
|
|
44
34
|
bytes = File.binread(source)
|
|
@@ -306,6 +296,7 @@ module Pray
|
|
|
306
296
|
raise Error.render("folder source directory missing: #{source_root}") unless File.directory?(source_root)
|
|
307
297
|
raise Error.render("no files listed in package manifest for #{source_root}") if relative_files.empty?
|
|
308
298
|
|
|
299
|
+
relative_root = relative_project_path(project, destination_root)
|
|
309
300
|
matched = false
|
|
310
301
|
relative_files.each do |relative|
|
|
311
302
|
next if !only.empty? && !only.include?(relative)
|
|
@@ -314,9 +305,9 @@ module Pray
|
|
|
314
305
|
source = File.join(source_root, relative)
|
|
315
306
|
raise Error.render("provisioned file missing: #{source}") unless File.file?(source)
|
|
316
307
|
|
|
317
|
-
destination = File.join(
|
|
308
|
+
destination = Pathname(File.join(relative_root, relative)).cleanpath.to_s
|
|
318
309
|
planned << PlannedProvisionedFile.new(
|
|
319
|
-
path:
|
|
310
|
+
path: destination,
|
|
320
311
|
source: source,
|
|
321
312
|
package: package_name,
|
|
322
313
|
export: export_name
|
data/lib/pray/render_dest.rb
CHANGED
|
@@ -7,6 +7,19 @@ module Pray
|
|
|
7
7
|
module RenderDest
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
def write_rendered_targets(project, rendered, previous_lockfile = nil)
|
|
11
|
+
validate_destinations!(project, previous_lockfile)
|
|
12
|
+
rendered.each do |target|
|
|
13
|
+
PathSafety.validate_destination_path!(target.path)
|
|
14
|
+
ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
15
|
+
path = File.join(project.project_root, target.path)
|
|
16
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
17
|
+
ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
|
|
18
|
+
write_rendered_content(path, target.path, target.content)
|
|
19
|
+
end
|
|
20
|
+
materialize(project, previous_lockfile)
|
|
21
|
+
end
|
|
22
|
+
|
|
10
23
|
def materialize(project, previous_lockfile = nil)
|
|
11
24
|
planned = Render.planned_provisioned_files(project)
|
|
12
25
|
previous = previous_map(previous_lockfile)
|
|
@@ -40,15 +53,40 @@ module Pray
|
|
|
40
53
|
Array(lockfile.provisioned).to_h { |record| [record.path, record] }
|
|
41
54
|
end
|
|
42
55
|
|
|
43
|
-
def destination_status(project, file, previous_lockfile = nil)
|
|
56
|
+
def destination_status(project, file, previous_lockfile = nil, previous = previous_map(previous_lockfile))
|
|
44
57
|
PathSafety.validate_destination_path!(file.path)
|
|
45
58
|
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
46
59
|
destination = File.join(project.project_root, file.path)
|
|
47
60
|
expected = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
|
|
48
|
-
record =
|
|
61
|
+
record = previous[file.path.to_s.tr("\\", "/")]
|
|
49
62
|
classify_destination(destination, file.path, expected, record)
|
|
50
63
|
end
|
|
51
64
|
|
|
65
|
+
def validate_destinations!(project, previous_lockfile = nil)
|
|
66
|
+
errors = []
|
|
67
|
+
statuses = []
|
|
68
|
+
omitted = 0
|
|
69
|
+
diagnostic_bytes = 0
|
|
70
|
+
previous = previous_map(previous_lockfile)
|
|
71
|
+
Render.planned_provisioned_files(project).each do |file|
|
|
72
|
+
statuses << [file, destination_status(project, file, previous_lockfile, previous)]
|
|
73
|
+
rescue Error => error
|
|
74
|
+
raise unless error.category == :render
|
|
75
|
+
|
|
76
|
+
message = "#{error.message} (package `#{file.package}`, export `#{file.export}`)"
|
|
77
|
+
if errors.length < 100 && diagnostic_bytes + message.bytesize < 60 * 1024
|
|
78
|
+
diagnostic_bytes += message.bytesize
|
|
79
|
+
errors << message
|
|
80
|
+
else
|
|
81
|
+
omitted += 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
errors << "#{omitted} additional destination conflicts omitted; resolve the listed paths and run `pray plan` again" if omitted > 0
|
|
85
|
+
raise Error.render(errors.join("\n")) unless errors.empty?
|
|
86
|
+
|
|
87
|
+
statuses
|
|
88
|
+
end
|
|
89
|
+
|
|
52
90
|
def write_leaf(project, file, previous)
|
|
53
91
|
PathSafety.validate_destination_path!(file.path)
|
|
54
92
|
ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
|
|
@@ -84,11 +122,11 @@ module Pray
|
|
|
84
122
|
return :update if record && Hashing.sha256_prefixed(on_disk) == record.content_hash
|
|
85
123
|
|
|
86
124
|
if record
|
|
87
|
-
raise Error.render("refusing to overwrite `#{display}`; it was
|
|
125
|
+
raise Error.render("refusing to overwrite `#{display}`; it was written by pray and then edited. Inspect your changes and move the file aside, then run `pray install`")
|
|
88
126
|
end
|
|
89
127
|
|
|
90
128
|
raise Error.render(
|
|
91
|
-
"refusing to overwrite `#{display}`;
|
|
129
|
+
"refusing to overwrite `#{display}`; its existing contents differ from this package. Inspect the file and move it aside, then run `pray install`. If an older pray wrote it, restore the original Prayfile and package version, run `pray install`, then retry the update"
|
|
92
130
|
)
|
|
93
131
|
end
|
|
94
132
|
|
|
@@ -104,7 +142,7 @@ module Pray
|
|
|
104
142
|
on_disk = read_regular_bytes(destination, record.path)
|
|
105
143
|
if Hashing.sha256_prefixed(on_disk) == record.content_hash
|
|
106
144
|
ensure_safe_destination_ancestors!(project.project_root, record.path, record.path)
|
|
107
|
-
File.delete(destination)
|
|
145
|
+
File.delete(destination) unless Transaction.replace(destination, on_disk, nil)
|
|
108
146
|
end
|
|
109
147
|
end
|
|
110
148
|
end
|
|
@@ -124,8 +162,10 @@ module Pray
|
|
|
124
162
|
return create_bytes(path, display, fresh) if destination_kind(path) == :missing
|
|
125
163
|
|
|
126
164
|
open_regular(path, display, File::RDWR) do |file|
|
|
127
|
-
existing = decode_utf8(file
|
|
165
|
+
existing = decode_utf8(read_destination_bytes(file, display), display)
|
|
128
166
|
content = RenderPatch.patch_rendered_content(existing, fresh)
|
|
167
|
+
return if Transaction.replace(path, existing.b, content.b)
|
|
168
|
+
|
|
129
169
|
file.rewind
|
|
130
170
|
file.truncate(0)
|
|
131
171
|
file.write(content)
|
|
@@ -158,6 +198,8 @@ module Pray
|
|
|
158
198
|
end
|
|
159
199
|
|
|
160
200
|
def create_bytes(path, display, bytes)
|
|
201
|
+
return if Transaction.replace(path, nil, bytes.b)
|
|
202
|
+
|
|
161
203
|
open_path(path, display, File::WRONLY | File::CREAT | File::EXCL) do |file|
|
|
162
204
|
file.write(bytes)
|
|
163
205
|
end
|
|
@@ -165,12 +207,14 @@ module Pray
|
|
|
165
207
|
|
|
166
208
|
def update_bytes(path, display, bytes, authorized_hash)
|
|
167
209
|
open_regular(path, display, File::RDWR) do |file|
|
|
168
|
-
on_disk = file
|
|
210
|
+
on_disk = read_destination_bytes(file, display)
|
|
169
211
|
return if on_disk.b == bytes.b
|
|
170
212
|
|
|
171
213
|
unless Hashing.sha256_prefixed(on_disk) == authorized_hash
|
|
172
|
-
raise Error.render("refusing to overwrite `#{display}`; it was
|
|
214
|
+
raise Error.render("refusing to overwrite `#{display}`; it was written by pray and then edited. Inspect your changes and move the file aside, then run `pray install`")
|
|
173
215
|
end
|
|
216
|
+
return if Transaction.replace(path, on_disk, bytes.b)
|
|
217
|
+
|
|
174
218
|
file.rewind
|
|
175
219
|
file.truncate(0)
|
|
176
220
|
file.write(bytes)
|
|
@@ -178,7 +222,30 @@ module Pray
|
|
|
178
222
|
end
|
|
179
223
|
|
|
180
224
|
def read_regular_bytes(path, display)
|
|
181
|
-
open_regular(path, display, File::RDONLY) { |file| file
|
|
225
|
+
open_regular(path, display, File::RDONLY) { |file| read_destination_bytes(file, display) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
MAX_DESTINATION_BYTES = 32 * 1024 * 1024
|
|
229
|
+
|
|
230
|
+
def read_destination_bytes(file, display)
|
|
231
|
+
if file.stat.size > MAX_DESTINATION_BYTES
|
|
232
|
+
raise Error.render("refusing to read `#{display}`; destination exceeds the 32 MiB limit")
|
|
233
|
+
end
|
|
234
|
+
bytes = +"".b
|
|
235
|
+
capacity = [file.stat.size + 1, 64 * 1024].min
|
|
236
|
+
loop do
|
|
237
|
+
chunk = file.read([capacity, MAX_DESTINATION_BYTES + 1 - bytes.bytesize].min)
|
|
238
|
+
break unless chunk
|
|
239
|
+
|
|
240
|
+
bytes << chunk
|
|
241
|
+
if bytes.bytesize > MAX_DESTINATION_BYTES
|
|
242
|
+
raise Error.render("refusing to read `#{display}`; destination exceeds the 32 MiB limit")
|
|
243
|
+
end
|
|
244
|
+
break if chunk.bytesize < capacity
|
|
245
|
+
|
|
246
|
+
capacity = 64 * 1024
|
|
247
|
+
end
|
|
248
|
+
bytes
|
|
182
249
|
end
|
|
183
250
|
|
|
184
251
|
def decode_utf8(bytes, display)
|
data/lib/pray/resolve.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Pray
|
|
4
4
|
ResolvedProject = Struct.new(
|
|
5
5
|
:manifest_path, :project_root, :manifest, :manifest_hash, :packages,
|
|
6
|
-
:local_files, :source_revisions, :source_host_keys, :environment
|
|
6
|
+
:local_files, :source_revisions, :source_host_keys, :environment, :previous_lockfile
|
|
7
7
|
) do
|
|
8
8
|
def lockfile_hash
|
|
9
9
|
manifest_hash
|
|
@@ -107,7 +107,8 @@ module Pray
|
|
|
107
107
|
local_files: local_files,
|
|
108
108
|
source_revisions: source_revisions,
|
|
109
109
|
source_host_keys: source_host_keys,
|
|
110
|
-
environment: options.environment
|
|
110
|
+
environment: options.environment,
|
|
111
|
+
previous_lockfile: lockfile_hints
|
|
111
112
|
)
|
|
112
113
|
end
|
|
113
114
|
|
|
@@ -126,14 +127,21 @@ module Pray
|
|
|
126
127
|
!refresh &&
|
|
127
128
|
resolution_may_benefit_from_git_source_refresh?(error)
|
|
128
129
|
refreshed = ResolveOptions.new(offline: offline, refresh: true, environment: environment)
|
|
129
|
-
|
|
130
|
+
begin
|
|
131
|
+
resolve_project_with_options(manifest_path, refreshed)
|
|
132
|
+
rescue Error => retry_error
|
|
133
|
+
raise GitRefresh.annotate_failed_refresh(
|
|
134
|
+
File.join(File.dirname(File.expand_path(manifest_path)), "Prayfile.lock"),
|
|
135
|
+
retry_error
|
|
136
|
+
)
|
|
137
|
+
end
|
|
130
138
|
else
|
|
131
139
|
raise
|
|
132
140
|
end
|
|
133
141
|
end
|
|
134
142
|
|
|
135
143
|
def resolution_may_benefit_from_git_source_refresh?(error)
|
|
136
|
-
|
|
144
|
+
GitRefresh.resolution_may_benefit_from_git_source_refresh?(error)
|
|
137
145
|
end
|
|
138
146
|
|
|
139
147
|
def missing_local_embed_guidance(path)
|
|
@@ -227,14 +235,23 @@ module Pray
|
|
|
227
235
|
clone_url = source.url.delete_prefix("git+")
|
|
228
236
|
distribution_root = GitSources.resolve_distribution_root(checkout.cache_directory, checkout.subdir)
|
|
229
237
|
source_key = checkout.revision.to_s.empty? ? clone_url : "#{clone_url}@#{checkout.revision}"
|
|
230
|
-
resolved =
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
+
resolved = begin
|
|
239
|
+
Registry.resolve_local_registry_package_root(
|
|
240
|
+
project_root,
|
|
241
|
+
source_key,
|
|
242
|
+
distribution_root,
|
|
243
|
+
declaration,
|
|
244
|
+
preferred_version: lockfile_preferred_version(lockfile, declaration.name),
|
|
245
|
+
offline: offline
|
|
246
|
+
)
|
|
247
|
+
rescue Error => error
|
|
248
|
+
raise GitRefresh.annotate_missing_git_catalog(
|
|
249
|
+
error,
|
|
250
|
+
package_name: declaration.name,
|
|
251
|
+
source_name: source.name,
|
|
252
|
+
revision: checkout.revision
|
|
253
|
+
)
|
|
254
|
+
end
|
|
238
255
|
return [resolved.root, resolved.registry_latest_version]
|
|
239
256
|
else
|
|
240
257
|
raise Error.unsupported("source kind #{source.kind} not implemented yet")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Pray
|
|
7
|
+
class TransactionJournal
|
|
8
|
+
MAX_LOG = 96 * 1024 * 1024
|
|
9
|
+
MAX_SAVED = 64 * 1024 * 1024
|
|
10
|
+
MAX_FILE = 32 * 1024 * 1024
|
|
11
|
+
attr_reader :root
|
|
12
|
+
|
|
13
|
+
def initialize(root, directory)
|
|
14
|
+
@root = root
|
|
15
|
+
@directory = directory
|
|
16
|
+
@path = File.join(directory, "journal")
|
|
17
|
+
@saved = 0
|
|
18
|
+
@entries = 0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def append(record)
|
|
22
|
+
bytes = JSON.generate(record) + "\n"
|
|
23
|
+
RenderDest.open_regular(@path, "project recovery journal", File::WRONLY | File::APPEND) do |file|
|
|
24
|
+
raise Error.render("project recovery journal exceeds its 96 MiB limit") if file.size + bytes.bytesize > MAX_LOG
|
|
25
|
+
|
|
26
|
+
file.write(bytes)
|
|
27
|
+
file.fsync
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def replace(path, before, after)
|
|
32
|
+
return if before == after
|
|
33
|
+
|
|
34
|
+
display = Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(root)).to_s.tr("\\", "/")
|
|
35
|
+
destination(display)
|
|
36
|
+
validate_budget(before, after)
|
|
37
|
+
raise changed(display) if self.class.snapshot(path) != before
|
|
38
|
+
|
|
39
|
+
mode = before.nil? ? 0o644 : File.lstat(path).mode & 0o777
|
|
40
|
+
unless File.exist?(@path)
|
|
41
|
+
TransactionOwner.private_file(@path, &:fsync)
|
|
42
|
+
append({"type" => "start", "version" => 1})
|
|
43
|
+
TransactionOwner.sync_directory(@directory)
|
|
44
|
+
end
|
|
45
|
+
append({"type" => "write", "entry" => {"path" => display,
|
|
46
|
+
"before" => before.nil? ? nil : Base64.strict_encode64(before),
|
|
47
|
+
"after_hash" => after.nil? ? nil : Hashing.sha256_prefixed(after), "mode" => mode}})
|
|
48
|
+
@saved += (before&.bytesize || 0) + (after&.bytesize || 0)
|
|
49
|
+
@entries += 1
|
|
50
|
+
install(display, before, after, mode)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_budget(before, after)
|
|
54
|
+
if [before, after].compact.any? { |bytes| bytes.bytesize > MAX_FILE }
|
|
55
|
+
raise Error.render("destination exceeds the 32 MiB limit")
|
|
56
|
+
end
|
|
57
|
+
if @saved + (before&.bytesize || 0) + (after&.bytesize || 0) > MAX_SAVED || @entries == 10_000
|
|
58
|
+
raise Error.render("project write exceeds the 64 MiB or 10000-file recovery limit")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def destination(display)
|
|
63
|
+
PathSafety.validate_destination_path!(display)
|
|
64
|
+
if display == ".pray/write-state" || display.start_with?(".pray/write-state/")
|
|
65
|
+
raise Error.render("destination overlaps project recovery state")
|
|
66
|
+
end
|
|
67
|
+
RenderDest.ensure_safe_destination_ancestors!(root, display, display)
|
|
68
|
+
File.join(root, display)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def install(display, expected, bytes, mode)
|
|
72
|
+
path = destination(display)
|
|
73
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
74
|
+
destination(display)
|
|
75
|
+
temporary = File.join(@directory, "#{SecureRandom.hex(16)}.stage")
|
|
76
|
+
unless bytes.nil?
|
|
77
|
+
TransactionOwner.private_file(temporary) do |file|
|
|
78
|
+
file.write(bytes)
|
|
79
|
+
file.chmod(mode & 0o777)
|
|
80
|
+
file.fsync
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
raise changed(display) if self.class.snapshot(path) != expected
|
|
84
|
+
|
|
85
|
+
destination(display)
|
|
86
|
+
if !bytes.nil? && expected.nil?
|
|
87
|
+
File.link(temporary, path)
|
|
88
|
+
File.unlink(temporary)
|
|
89
|
+
elsif !bytes.nil?
|
|
90
|
+
File.rename(temporary, path)
|
|
91
|
+
elsif !expected.nil?
|
|
92
|
+
File.unlink(path)
|
|
93
|
+
end
|
|
94
|
+
sync_parents(path)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def sync_parents(path)
|
|
98
|
+
parent = File.dirname(path)
|
|
99
|
+
loop do
|
|
100
|
+
begin
|
|
101
|
+
TransactionOwner.sync_directory(parent)
|
|
102
|
+
rescue Errno::ENOENT
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
break if parent == root
|
|
106
|
+
|
|
107
|
+
parent = File.dirname(parent)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def recover
|
|
112
|
+
clean_stages
|
|
113
|
+
return unless File.exist?(@path)
|
|
114
|
+
|
|
115
|
+
entries, undone, committed = TransactionRecords.read(@path)
|
|
116
|
+
unless committed
|
|
117
|
+
(entries.length - 1).downto(0) do |index|
|
|
118
|
+
next if undone.include?(index)
|
|
119
|
+
|
|
120
|
+
restore(entries[index])
|
|
121
|
+
append({"type" => "undone", "index" => index})
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
File.unlink(@path)
|
|
125
|
+
TransactionOwner.sync_directory(@directory)
|
|
126
|
+
@saved = @entries = 0
|
|
127
|
+
clean_stages
|
|
128
|
+
rescue ArgumentError
|
|
129
|
+
raise Error.render("invalid recovery bytes")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def restore(entry)
|
|
133
|
+
before = entry["before"].nil? ? nil : Base64.strict_decode64(entry["before"])
|
|
134
|
+
raise Error.render("recovery file exceeds 32 MiB") if before && before.bytesize > MAX_FILE
|
|
135
|
+
|
|
136
|
+
current = self.class.snapshot(destination(entry["path"]))
|
|
137
|
+
if current != before
|
|
138
|
+
current_hash = current.nil? ? nil : Hashing.sha256_prefixed(current)
|
|
139
|
+
raise changed(entry["path"]) if !current.nil? && current_hash != entry["after_hash"]
|
|
140
|
+
|
|
141
|
+
install(entry["path"], current, before, entry["mode"])
|
|
142
|
+
end
|
|
143
|
+
# A prior recovery may have stopped before its directory sync completed.
|
|
144
|
+
sync_parents(destination(entry["path"]))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def commit
|
|
148
|
+
if File.exist?(@path)
|
|
149
|
+
append({"type" => "commit"})
|
|
150
|
+
File.unlink(@path)
|
|
151
|
+
TransactionOwner.sync_directory(@directory)
|
|
152
|
+
end
|
|
153
|
+
clean_stages
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def clean_stages
|
|
157
|
+
Dir.children(@directory).each do |name|
|
|
158
|
+
File.unlink(File.join(@directory, name)) if name.match?(/\A[a-f0-9]{32}\.stage\z/)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.snapshot(path)
|
|
163
|
+
(RenderDest.destination_kind(path) == :missing) ? nil : RenderDest.read_regular_bytes(path, path)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def changed(display)
|
|
167
|
+
Error.render("`#{display}` changed during the interrupted write. Inspect your changes and move the file aside, then retry; recovery data remains in .pray/write-state")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Pray
|
|
8
|
+
module TransactionOwner
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def acquire(directory, path = File.join(directory, "owner"), depth = 0)
|
|
12
|
+
raise Error.render("too many interrupted lock recoveries; inspect .pray/write-state") if depth > 16
|
|
13
|
+
|
|
14
|
+
owner = {"version" => 1, "pid" => Process.pid, "host" => Socket.gethostname, "token" => SecureRandom.hex(16)}
|
|
15
|
+
linked = publish_owner(directory, path, owner)
|
|
16
|
+
if linked
|
|
17
|
+
sync_directory(directory)
|
|
18
|
+
return -> { File.unlink(path) if read_owner(path)["token"] == owner["token"] }
|
|
19
|
+
end
|
|
20
|
+
previous = read_owner(path)
|
|
21
|
+
if previous["host"] != Socket.gethostname || alive?(previous["pid"])
|
|
22
|
+
raise Error.render("another pray command owns this project; retry after it finishes")
|
|
23
|
+
end
|
|
24
|
+
# The dead owner's unique token serializes stale-lock removal.
|
|
25
|
+
release = acquire(directory, File.join(directory, "reclaim-#{previous["token"]}"), depth + 1)
|
|
26
|
+
begin
|
|
27
|
+
if read_owner(path)["token"] != previous["token"]
|
|
28
|
+
raise Error.render("project ownership changed; retry the command")
|
|
29
|
+
end
|
|
30
|
+
File.unlink(path)
|
|
31
|
+
acquire(directory, path, depth + 1)
|
|
32
|
+
ensure
|
|
33
|
+
release.call
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def publish_owner(directory, path, owner)
|
|
38
|
+
temporary = File.join(directory, "#{owner["token"]}.owner")
|
|
39
|
+
private_file(temporary) { |file|
|
|
40
|
+
file.write(JSON.generate(owner))
|
|
41
|
+
file.fsync
|
|
42
|
+
}
|
|
43
|
+
begin
|
|
44
|
+
File.link(temporary, path)
|
|
45
|
+
true
|
|
46
|
+
rescue Errno::EEXIST
|
|
47
|
+
false
|
|
48
|
+
ensure
|
|
49
|
+
File.unlink(temporary)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def read_owner(path)
|
|
54
|
+
bytes = RenderDest.read_regular_bytes(path, "project write owner")
|
|
55
|
+
raise Error.render("invalid project write owner") if bytes.bytesize > 4096
|
|
56
|
+
|
|
57
|
+
owner = JSON.parse(bytes)
|
|
58
|
+
unless owner.is_a?(Hash) && owner["version"] == 1 && owner["pid"].is_a?(Integer) && owner["pid"] > 0 &&
|
|
59
|
+
owner["host"].is_a?(String) && owner["token"].is_a?(String) && owner["token"].match?(/\A[a-f0-9]{32}\z/)
|
|
60
|
+
raise Error.render("invalid project write owner")
|
|
61
|
+
end
|
|
62
|
+
owner
|
|
63
|
+
rescue JSON::ParserError
|
|
64
|
+
raise Error.render("invalid project write owner")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def alive?(pid)
|
|
68
|
+
Process.kill(0, pid)
|
|
69
|
+
true
|
|
70
|
+
rescue Errno::ESRCH
|
|
71
|
+
false
|
|
72
|
+
rescue Errno::EPERM, RangeError
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def private_file(path, &block)
|
|
77
|
+
no_follow = File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0
|
|
78
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL | no_follow, 0o600, &block)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sync_directory(path)
|
|
82
|
+
return if Gem.win_platform?
|
|
83
|
+
|
|
84
|
+
File.open(path, File::RDONLY, &:fsync)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module TransactionRecords
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def read(path)
|
|
8
|
+
bytes = RenderDest.open_regular(path, "project recovery journal", File::RDWR) do |file|
|
|
9
|
+
raise Error.render("project recovery journal exceeds its 96 MiB limit") if file.size > TransactionJournal::MAX_LOG
|
|
10
|
+
|
|
11
|
+
contents = file.read(TransactionJournal::MAX_LOG + 1) || ""
|
|
12
|
+
raise Error.render("project recovery journal exceeds its 96 MiB limit") if contents.bytesize > TransactionJournal::MAX_LOG
|
|
13
|
+
|
|
14
|
+
complete = (contents.rindex("\n") || -1) + 1
|
|
15
|
+
file.truncate(complete)
|
|
16
|
+
file.fsync
|
|
17
|
+
contents.byteslice(0, complete)
|
|
18
|
+
end
|
|
19
|
+
parse(bytes)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse(bytes)
|
|
23
|
+
entries = []
|
|
24
|
+
undone = {}
|
|
25
|
+
committed = false
|
|
26
|
+
bytes.lines.each_with_index do |line, index|
|
|
27
|
+
record = JSON.parse(line)
|
|
28
|
+
case record["type"]
|
|
29
|
+
when "start"
|
|
30
|
+
invalid! unless record["version"] == 1 && index == 0
|
|
31
|
+
when "write"
|
|
32
|
+
invalid! unless index > 0 && !committed && undone.empty?
|
|
33
|
+
validate_entry(record["entry"], entries.length)
|
|
34
|
+
entries << record["entry"]
|
|
35
|
+
when "undone"
|
|
36
|
+
invalid! unless !committed && record["index"].is_a?(Integer) && (0...entries.length).cover?(record["index"])
|
|
37
|
+
undone[record["index"]] = true
|
|
38
|
+
when "commit"
|
|
39
|
+
invalid! unless index > 0 && undone.empty? && !committed
|
|
40
|
+
committed = true
|
|
41
|
+
else
|
|
42
|
+
invalid!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
[entries, undone, committed]
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
invalid!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_entry(entry, count)
|
|
51
|
+
invalid! if count >= 10_000
|
|
52
|
+
invalid! unless entry.is_a?(Hash) && entry["path"].is_a?(String)
|
|
53
|
+
invalid! unless entry["mode"].is_a?(Integer) && (0..0o777).cover?(entry["mode"])
|
|
54
|
+
invalid! unless entry["before"].nil? || entry["before"].is_a?(String)
|
|
55
|
+
if entry["after_hash"]
|
|
56
|
+
invalid! unless entry["after_hash"].is_a?(String) && entry["after_hash"].match?(/\Asha256:[a-f0-9]{64}\z/)
|
|
57
|
+
end
|
|
58
|
+
PathSafety.validate_destination_path!(entry["path"])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invalid!
|
|
62
|
+
raise Error.render("invalid project recovery journal state")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "transaction/owner"
|
|
4
|
+
require_relative "transaction/journal"
|
|
5
|
+
require_relative "transaction/records"
|
|
6
|
+
|
|
7
|
+
module Pray
|
|
8
|
+
module Transaction
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def run(root)
|
|
12
|
+
root = File.expand_path(root)
|
|
13
|
+
current = Thread.current[:pray_write_transaction]
|
|
14
|
+
return yield if current && current.root == root
|
|
15
|
+
raise Error.render("a command cannot write two projects in one transaction") if current
|
|
16
|
+
|
|
17
|
+
RenderDest.ensure_safe_destination_ancestors!(root, ".pray/write-state/owner", "project write state")
|
|
18
|
+
directory = File.join(root, ".pray/write-state")
|
|
19
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
20
|
+
RenderDest.ensure_safe_destination_ancestors!(root, ".pray/write-state/owner", "project write state")
|
|
21
|
+
File.open(directory, File::RDONLY | File::NOFOLLOW) { |file| file.chmod(0o700) }
|
|
22
|
+
[directory, File.join(root, ".pray"), root].each { |path| TransactionOwner.sync_directory(path) }
|
|
23
|
+
release = TransactionOwner.acquire(directory)
|
|
24
|
+
begin
|
|
25
|
+
journal = TransactionJournal.new(root, directory)
|
|
26
|
+
journal.recover
|
|
27
|
+
Thread.current[:pray_write_transaction] = journal
|
|
28
|
+
begin
|
|
29
|
+
result = yield
|
|
30
|
+
rescue => error
|
|
31
|
+
begin
|
|
32
|
+
journal.recover
|
|
33
|
+
rescue => recovery
|
|
34
|
+
raise Error.render("#{error}\nRecovery stopped: #{recovery}")
|
|
35
|
+
end
|
|
36
|
+
raise
|
|
37
|
+
end
|
|
38
|
+
journal.commit
|
|
39
|
+
result
|
|
40
|
+
ensure
|
|
41
|
+
Thread.current[:pray_write_transaction] = nil
|
|
42
|
+
release.call
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def replace(path, before, after)
|
|
47
|
+
journal = Thread.current[:pray_write_transaction]
|
|
48
|
+
return false unless journal
|
|
49
|
+
|
|
50
|
+
journal.replace(path, before, after)
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def write_file(path, bytes)
|
|
55
|
+
journal = Thread.current[:pray_write_transaction]
|
|
56
|
+
return File.binwrite(path, bytes) unless journal
|
|
57
|
+
|
|
58
|
+
journal.replace(path, TransactionJournal.snapshot(path), bytes.b)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/pray/trust.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "perfect_toml"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
6
|
module Pray
|
|
@@ -44,8 +44,8 @@ module Pray
|
|
|
44
44
|
path = trust_policy_path(home)
|
|
45
45
|
return nil unless File.file?(path)
|
|
46
46
|
|
|
47
|
-
parse_policy(
|
|
48
|
-
rescue
|
|
47
|
+
parse_policy(PerfectTOML.parse(File.read(path, encoding: "UTF-8")))
|
|
48
|
+
rescue PerfectTOML::ParseError => error
|
|
49
49
|
raise Error.parse("trust policy", "#{path}: #{error.message}")
|
|
50
50
|
end
|
|
51
51
|
|
data/lib/pray/trust_feed.rb
CHANGED
|
@@ -44,7 +44,7 @@ module Pray
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def parse_compromised_toml(body)
|
|
47
|
-
data =
|
|
47
|
+
data = PerfectTOML.parse(body)
|
|
48
48
|
Array(data["keys"]).filter_map do |entry|
|
|
49
49
|
key = normalize_key(entry["value"].to_s)
|
|
50
50
|
next if key.empty?
|
|
@@ -54,7 +54,7 @@ module Pray
|
|
|
54
54
|
reference: entry["reference"], reported_at: entry["reported_at"]
|
|
55
55
|
)
|
|
56
56
|
end
|
|
57
|
-
rescue
|
|
57
|
+
rescue PerfectTOML::ParseError
|
|
58
58
|
[]
|
|
59
59
|
end
|
|
60
60
|
|
data/lib/pray/verify.rb
CHANGED
|
@@ -114,7 +114,7 @@ module Pray
|
|
|
114
114
|
next
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
text =
|
|
117
|
+
text = RenderDest.decode_utf8(RenderDest.read_regular_bytes(absolute_path, target_path), target_path)
|
|
118
118
|
rendered_targets[target_path] = text
|
|
119
119
|
lines = text.lines(chomp: true)
|
|
120
120
|
markers = marker_positions(lines)
|
|
@@ -165,7 +165,7 @@ module Pray
|
|
|
165
165
|
)
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
-
VerifyProvisioned.push_findings(project, report)
|
|
168
|
+
VerifyProvisioned.push_findings(project, report, lockfile)
|
|
169
169
|
|
|
170
170
|
[report, rendered_targets, fresh_targets]
|
|
171
171
|
end
|
|
@@ -4,8 +4,9 @@ module Pray
|
|
|
4
4
|
module VerifyProvisioned
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
-
def push_findings(project, report)
|
|
7
|
+
def push_findings(project, report, lockfile)
|
|
8
8
|
push_exclusive_file_export_findings(project, report)
|
|
9
|
+
previous = RenderDest.previous_map(lockfile)
|
|
9
10
|
Render.planned_provisioned_files(project).each do |file|
|
|
10
11
|
path_text = file.path.to_s.tr("\\", "/")
|
|
11
12
|
absolute = File.join(project.project_root, file.path)
|
|
@@ -23,13 +24,20 @@ module Pray
|
|
|
23
24
|
)
|
|
24
25
|
next
|
|
25
26
|
end
|
|
26
|
-
destination_bytes =
|
|
27
|
+
destination_bytes = RenderDest.read_regular_bytes(absolute, path_text)
|
|
27
28
|
expected_bytes = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
|
|
28
|
-
|
|
29
|
+
destination_hash = Hashing.sha256_prefixed(destination_bytes)
|
|
30
|
+
next if destination_hash == Hashing.sha256_prefixed(expected_bytes.b)
|
|
29
31
|
|
|
32
|
+
owned = previous[path_text]&.content_hash == destination_hash
|
|
33
|
+
recovery = if owned
|
|
34
|
+
"Run `pray install` to restore it."
|
|
35
|
+
else
|
|
36
|
+
"Inspect your changes and move the file aside, then run `pray install` to restore it."
|
|
37
|
+
end
|
|
30
38
|
report.findings << VerificationFinding.new(
|
|
31
39
|
kind: "package_integrity",
|
|
32
|
-
message: "Provisioned file `#{path_text}` no longer matches package `#{file.package}`.
|
|
40
|
+
message: "Provisioned file `#{path_text}` no longer matches package `#{file.package}`. #{recovery}"
|
|
33
41
|
)
|
|
34
42
|
end
|
|
35
43
|
end
|
data/lib/pray/version.rb
CHANGED
data/lib/pray.rb
CHANGED
|
@@ -22,11 +22,13 @@ require_relative "pray/resolve_context"
|
|
|
22
22
|
require_relative "pray/resolve_source"
|
|
23
23
|
require_relative "pray/resolve_exports"
|
|
24
24
|
require_relative "pray/invocation"
|
|
25
|
+
require_relative "pray/git_refresh"
|
|
25
26
|
require_relative "pray/resolve"
|
|
26
27
|
require_relative "pray/compose_dest"
|
|
27
28
|
require_relative "pray/render_patch"
|
|
28
29
|
require_relative "pray/render"
|
|
29
30
|
require_relative "pray/render_dest"
|
|
31
|
+
require_relative "pray/transaction"
|
|
30
32
|
require_relative "pray/render_layout"
|
|
31
33
|
require_relative "pray/verify_position"
|
|
32
34
|
require_relative "pray/verify_provisioned"
|
data/pray-cli.gemspec
CHANGED
|
@@ -39,7 +39,7 @@ Gem::Specification.new do |spec|
|
|
|
39
39
|
"rubygems_mfa_required" => "true"
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
spec.add_dependency "
|
|
42
|
+
spec.add_dependency "perfect_toml", "~> 0.9", ">= 0.9.1"
|
|
43
43
|
spec.add_dependency "base64", "~> 0.2"
|
|
44
44
|
|
|
45
45
|
spec.add_development_dependency "rspec", "~> 3.13"
|
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.
|
|
4
|
+
version: 1.12.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
@@ -10,19 +10,25 @@ cert_chain: []
|
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
|
-
name:
|
|
13
|
+
name: perfect_toml
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '0.9'
|
|
19
|
+
- - ">="
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.9.1
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
26
|
- - "~>"
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
28
|
+
version: '0.9'
|
|
29
|
+
- - ">="
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.9.1
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: base64
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -124,6 +130,7 @@ files:
|
|
|
124
130
|
- lib/pray/error.rb
|
|
125
131
|
- lib/pray/format_manifest.rb
|
|
126
132
|
- lib/pray/format_serialize.rb
|
|
133
|
+
- lib/pray/git_refresh.rb
|
|
127
134
|
- lib/pray/git_sources.rb
|
|
128
135
|
- lib/pray/hashing.rb
|
|
129
136
|
- lib/pray/http_body.rb
|
|
@@ -166,6 +173,10 @@ files:
|
|
|
166
173
|
- lib/pray/sync.rb
|
|
167
174
|
- lib/pray/tar_validation.rb
|
|
168
175
|
- lib/pray/terminal.rb
|
|
176
|
+
- lib/pray/transaction.rb
|
|
177
|
+
- lib/pray/transaction/journal.rb
|
|
178
|
+
- lib/pray/transaction/owner.rb
|
|
179
|
+
- lib/pray/transaction/records.rb
|
|
169
180
|
- lib/pray/trust.rb
|
|
170
181
|
- lib/pray/trust_feed.rb
|
|
171
182
|
- lib/pray/trust_ops.rb
|