pray-cli 1.10.0 → 1.12.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 +19 -0
- data/lib/pray/cache_clean.rb +76 -0
- data/lib/pray/cli/commands/meta.rb +9 -4
- data/lib/pray/cli/commands/packages.rb +2 -2
- data/lib/pray/cli/commands/workflow.rb +9 -1
- data/lib/pray/cli/help.rb +3 -3
- data/lib/pray/cli/parse.rb +19 -1
- data/lib/pray/cli.rb +7 -2
- data/lib/pray/config.rb +3 -3
- data/lib/pray/lockfile.rb +5 -5
- data/lib/pray/materialize.rb +8 -4
- data/lib/pray/path_safety.rb +20 -0
- data/lib/pray/plan.rb +7 -9
- data/lib/pray/registry.rb +6 -13
- data/lib/pray/render.rb +5 -14
- data/lib/pray/render_dest.rb +76 -9
- data/lib/pray/resolve.rb +3 -2
- data/lib/pray/tar_validation.rb +4 -1
- 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 +1 -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: 68bb4b372a260d62b3cfce3bdc74811b85c2cff0446adf0bd551065b0214fbb1
|
|
4
|
+
data.tar.gz: 3bfc7db4cd0b6227a554199f6c8cbf8fd2e4c1e2715be48f7d102903e7a26bb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '095670c5e563280d0610d1eefa2ef2a825e2676aaff537c54eda62ad97ee11a631dc5328648614bfdd69b6dca744c7377fea9fef3b5cc0c4d3341ad511adb4be'
|
|
7
|
+
data.tar.gz: a820d50b02742e1b8a7c79fe9075f48e6ae7c90f3551dba8e8fbb37c7ed28d2e7089ac9fe81b82c2db7e8bf7d20f926840084d841e30bb3d2a733efa68e2892f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 1.12.0 (2026-09-07)
|
|
6
|
+
|
|
7
|
+
- Speed up planning for large package trees and reuse the resolved lock during installation.
|
|
8
|
+
- Report duplicate lockfile fields as parsing errors with lockfile context.
|
|
9
|
+
- Restore compose files, provisioned destinations, Prayfile, and lock after failed writes, and recover interrupted writes on the next install, plan, or verification on Unix.
|
|
10
|
+
- Preserve later local edits when recovery encounters changed files, and prevent cooperating Pray commands from writing the same project together.
|
|
11
|
+
- Limit destination reads to 32 MiB, saved transaction payload to 64 MiB across 10,000 writes, and grouped conflict details to 100 entries.
|
|
12
|
+
- Report conflicting file and tree destinations together before changing compose output, with steps that preserve local edits.
|
|
13
|
+
- Reject unsupported update options (`--latest`, `--major`, `--dry-run`, and `--json`) instead of silently ignoring them.
|
|
14
|
+
|
|
15
|
+
## 1.11.0 (2026-09-04)
|
|
16
|
+
|
|
17
|
+
- Read the whole eight byte tar checksum field so `.praypkg` archives whose checksum is written as seven octal digits unpack instead of failing integrity.
|
|
18
|
+
- Share the source-keyed registry cache path with the Rust and TypeScript CLIs.
|
|
19
|
+
- Add `pray clean --unused` for lockfile-driven registry cache cleanup.
|
|
20
|
+
- Reject unsafe registry package and version path segments.
|
|
21
|
+
|
|
3
22
|
## 1.10.0 (2026-09-02)
|
|
4
23
|
|
|
5
24
|
- Refuse to overwrite exclusive `file:` and `tree:` destinations that already exist with other bytes.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Pray
|
|
6
|
+
module CacheClean
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def clean_unused_registry_cache(project_root)
|
|
10
|
+
lockfile = Pray.read_lockfile(File.join(project_root, "Prayfile.lock"))
|
|
11
|
+
validate_lockfile_for_cleanup!(lockfile)
|
|
12
|
+
registry_root = File.expand_path(".pray/cache/registry", project_root)
|
|
13
|
+
retained = lockfile.package.filter_map do |package|
|
|
14
|
+
package_path = File.expand_path(package.path, project_root)
|
|
15
|
+
package_path if PathSafety.path_under_root?(registry_root, package_path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
metadata = File.lstat(registry_root)
|
|
20
|
+
rescue Errno::ENOENT
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
if metadata.symlink? || !metadata.directory?
|
|
24
|
+
remove_path(registry_root)
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
prune_directory(registry_root, retained, remove_when_empty: false)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_lockfile_for_cleanup!(lockfile)
|
|
31
|
+
validate_sha256_digest!("manifest_hash", lockfile.manifest_hash)
|
|
32
|
+
lockfile.package.each do |package|
|
|
33
|
+
if !package.path.is_a?(String) || package.path.empty?
|
|
34
|
+
raise Error.parse("lockfile", "package path must not be empty")
|
|
35
|
+
end
|
|
36
|
+
validate_sha256_digest!("package tree_hash", package.tree_hash)
|
|
37
|
+
validate_sha256_digest!("package artifact_hash", package.artifact_hash)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_sha256_digest!(field, value)
|
|
42
|
+
digest = value.to_s.delete_prefix("sha256:")
|
|
43
|
+
return if value.is_a?(String) && value.start_with?("sha256:") && digest.match?(/\A[0-9a-f]{64}\z/)
|
|
44
|
+
|
|
45
|
+
raise Error.parse("lockfile", "#{field} must be a sha256 digest")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def prune_directory(path, retained, remove_when_empty:)
|
|
49
|
+
Dir.each_child(path) do |name|
|
|
50
|
+
entry = File.join(path, name)
|
|
51
|
+
protects_entry = retained.include?(entry)
|
|
52
|
+
leads_to_retained = retained.any? { |kept| PathSafety.path_under_root?(entry, kept) }
|
|
53
|
+
next if protects_entry
|
|
54
|
+
|
|
55
|
+
metadata = File.lstat(entry)
|
|
56
|
+
if leads_to_retained && metadata.directory? && !metadata.symlink?
|
|
57
|
+
prune_directory(entry, retained, remove_when_empty: true)
|
|
58
|
+
else
|
|
59
|
+
remove_path(entry)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
Dir.rmdir(path) if remove_when_empty && Dir.empty?(path)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def remove_path(path)
|
|
66
|
+
metadata = File.lstat(path)
|
|
67
|
+
if metadata.directory? && !metadata.symlink?
|
|
68
|
+
FileUtils.rm_rf(path)
|
|
69
|
+
else
|
|
70
|
+
File.unlink(path)
|
|
71
|
+
end
|
|
72
|
+
rescue Errno::ENOENT
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -6,10 +6,15 @@ module Pray
|
|
|
6
6
|
puts "pray #{Pray::VERSION}"
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
def clean_command
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
def clean_command(unused: false)
|
|
10
|
+
project_root = Invocation.project_root
|
|
11
|
+
if unused
|
|
12
|
+
CacheClean.clean_unused_registry_cache(project_root)
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
remove_path_if_exists(File.join(project_root, ".pray/cache"))
|
|
16
|
+
remove_path_if_exists(File.join(project_root, ".pray/vendor"))
|
|
17
|
+
remove_path_if_exists(File.join(project_root, ".pray/state.json"))
|
|
13
18
|
end
|
|
14
19
|
end
|
|
15
20
|
end
|
|
@@ -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
|
|
@@ -117,7 +117,7 @@ module Pray
|
|
|
117
117
|
"remove" => "remove a package from Prayfile\n\nUsage: pray remove <name>",
|
|
118
118
|
"unlock" => "clear a locked package pin\n\nUsage: pray unlock <package>",
|
|
119
119
|
"vendor" => "copy resolved packages locally\n\nUsage: pray vendor",
|
|
120
|
-
"clean" => "remove local cache and vendor trees\n\nUsage: pray clean",
|
|
120
|
+
"clean" => "remove local cache and vendor trees, or only unused registry entries\n\nUsage: pray clean [--unused]",
|
|
121
121
|
"publish" => <<~TEXT.strip,
|
|
122
122
|
upload packages to a registry or local root
|
|
123
123
|
|
data/lib/pray/cli/parse.rb
CHANGED
|
@@ -67,7 +67,7 @@ module Pray
|
|
|
67
67
|
when "outdated" then [:outdated, arguments]
|
|
68
68
|
when "explain" then [:explain, arguments.shift]
|
|
69
69
|
when "vendor" then [:vendor]
|
|
70
|
-
when "clean" then [:clean]
|
|
70
|
+
when "clean" then [:clean, parse_clean_arguments(arguments, flags)]
|
|
71
71
|
when "tree" then [:tree]
|
|
72
72
|
when "sync" then [:sync, parse_sync_arguments(arguments)]
|
|
73
73
|
when "trust" then parse_trust_command(arguments)
|
|
@@ -83,6 +83,24 @@ module Pray
|
|
|
83
83
|
found
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
def parse_clean_arguments(arguments, flags)
|
|
87
|
+
if flags.values_at(:check, :strict, :semantic, :locked, :frozen, :offline).any?
|
|
88
|
+
raise Error.unsupported("unknown clean flag")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
unused = false
|
|
92
|
+
arguments.each do |argument|
|
|
93
|
+
if argument == "--unused" && !unused
|
|
94
|
+
unused = true
|
|
95
|
+
elsif argument.start_with?("--")
|
|
96
|
+
raise Error.unsupported("unknown clean flag: #{argument}")
|
|
97
|
+
else
|
|
98
|
+
raise Error.unsupported("unexpected clean argument: #{argument}")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
{unused: unused}
|
|
102
|
+
end
|
|
103
|
+
|
|
86
104
|
def parse_add_arguments(arguments)
|
|
87
105
|
name = arguments.shift
|
|
88
106
|
raise Error.unsupported("add requires a package name") unless name
|
data/lib/pray/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
require_relative "invocation"
|
|
6
|
+
require_relative "cache_clean"
|
|
6
7
|
require_relative "cli/parse"
|
|
7
8
|
require_relative "cli/help"
|
|
8
9
|
require_relative "cli/suggest"
|
|
@@ -29,7 +30,11 @@ module Pray
|
|
|
29
30
|
return if maybe_print_help(arguments)
|
|
30
31
|
|
|
31
32
|
command = parse_command(arguments)
|
|
32
|
-
|
|
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
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
def maybe_print_help(arguments)
|
|
@@ -97,7 +102,7 @@ module Pray
|
|
|
97
102
|
in [:outdated, arguments] then outdated_command(arguments)
|
|
98
103
|
in [:explain, name] then explain_command(name)
|
|
99
104
|
in [:vendor] then raise Error.unsupported("vendor is not implemented yet in pray-cli Ruby")
|
|
100
|
-
in [:clean] then clean_command
|
|
105
|
+
in [:clean, options] then clean_command(**options)
|
|
101
106
|
in [:tree] then tree_command
|
|
102
107
|
in [:trust_list, options] then trust_list_command(**options)
|
|
103
108
|
in [:trust_show] then trust_show_command
|
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
|
|
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,
|
|
@@ -40,7 +44,7 @@ module Pray
|
|
|
40
44
|
end
|
|
41
45
|
rendered = Render.render_project(project)
|
|
42
46
|
lockfile_path = default_lockfile_path(project.project_root)
|
|
43
|
-
previous_lockfile =
|
|
47
|
+
previous_lockfile = project.previous_lockfile
|
|
44
48
|
next_lockfile = LockfileIO.build_lockfile(
|
|
45
49
|
project.manifest_hash,
|
|
46
50
|
project.environment,
|
|
@@ -58,7 +62,7 @@ module Pray
|
|
|
58
62
|
unless File.exist?(lockfile_path)
|
|
59
63
|
raise Error.verify("missing Prayfile.lock; run install first")
|
|
60
64
|
end
|
|
61
|
-
existing =
|
|
65
|
+
existing = previous_lockfile
|
|
62
66
|
unless Pray.lockfiles_equivalent?(existing, next_lockfile)
|
|
63
67
|
raise Error.verify("lockfile needs update; rerun install to refresh Prayfile.lock")
|
|
64
68
|
end
|
|
@@ -80,7 +84,7 @@ module Pray
|
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
if frozen
|
|
83
|
-
existing =
|
|
87
|
+
existing = previous_lockfile
|
|
84
88
|
if existing
|
|
85
89
|
rendered.each do |target|
|
|
86
90
|
output_path = File.join(project.project_root, target.path)
|
data/lib/pray/path_safety.rb
CHANGED
|
@@ -80,5 +80,25 @@ module Pray
|
|
|
80
80
|
|
|
81
81
|
validate_project_relative_path!(value)
|
|
82
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?
|
|
83
103
|
end
|
|
84
104
|
end
|
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/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
|
@@ -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
|
|
data/lib/pray/tar_validation.rb
CHANGED
|
@@ -102,7 +102,10 @@ module Pray
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def verify_checksum!(header)
|
|
105
|
-
|
|
105
|
+
# The checksum field is eight bytes. Writers fill it differently: six octal
|
|
106
|
+
# digits then NUL and space, seven digits then NUL, or space padding. Read the
|
|
107
|
+
# whole field and let parse_octal strip the terminator.
|
|
108
|
+
stored = parse_octal(header.byteslice(148, 8), "checksum")
|
|
106
109
|
sum = 0
|
|
107
110
|
header.bytes.each_with_index do |byte, index|
|
|
108
111
|
sum += (index >= 148 && index < 156) ? 32 : byte
|
|
@@ -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
|
@@ -27,6 +27,7 @@ require_relative "pray/compose_dest"
|
|
|
27
27
|
require_relative "pray/render_patch"
|
|
28
28
|
require_relative "pray/render"
|
|
29
29
|
require_relative "pray/render_dest"
|
|
30
|
+
require_relative "pray/transaction"
|
|
30
31
|
require_relative "pray/render_layout"
|
|
31
32
|
require_relative "pray/verify_position"
|
|
32
33
|
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.0
|
|
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
|
|
@@ -99,6 +105,7 @@ files:
|
|
|
99
105
|
- lib/pray/archive.rb
|
|
100
106
|
- lib/pray/archive_unpack.rb
|
|
101
107
|
- lib/pray/auth_client.rb
|
|
108
|
+
- lib/pray/cache_clean.rb
|
|
102
109
|
- lib/pray/cli.rb
|
|
103
110
|
- lib/pray/cli/commands/auth.rb
|
|
104
111
|
- lib/pray/cli/commands/distribution.rb
|
|
@@ -165,6 +172,10 @@ files:
|
|
|
165
172
|
- lib/pray/sync.rb
|
|
166
173
|
- lib/pray/tar_validation.rb
|
|
167
174
|
- lib/pray/terminal.rb
|
|
175
|
+
- lib/pray/transaction.rb
|
|
176
|
+
- lib/pray/transaction/journal.rb
|
|
177
|
+
- lib/pray/transaction/owner.rb
|
|
178
|
+
- lib/pray/transaction/records.rb
|
|
168
179
|
- lib/pray/trust.rb
|
|
169
180
|
- lib/pray/trust_feed.rb
|
|
170
181
|
- lib/pray/trust_ops.rb
|