pray-cli 1.11.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cce969add8d5bb73ee6fc4b9910d785a0565e24afaeb6d4b80d9383f9fbe828b
4
- data.tar.gz: f96cac68f4cf12079056d0ef300c57ab0efa47f3c9ab1e9967a9fe03cb8a5c37
3
+ metadata.gz: 68bb4b372a260d62b3cfce3bdc74811b85c2cff0446adf0bd551065b0214fbb1
4
+ data.tar.gz: 3bfc7db4cd0b6227a554199f6c8cbf8fd2e4c1e2715be48f7d102903e7a26bb2
5
5
  SHA512:
6
- metadata.gz: a0ec1da337a61097a65ebd7dc091879a978bbb1f13d6eba41ee4149735e8d37b3d32a7634e5b79fbc426e7ef068cf49ac5437c71b9492fe09f5ab439b1c5645b
7
- data.tar.gz: 0ea56f7dd07ddf9be173bc0dd3ed399b91d280c36aaea1f703d0a60b9676dba15398cef53c876f71b3c86347611b9dd5f10af6d716152a69e4bf488d7b82996c
6
+ metadata.gz: '095670c5e563280d0610d1eefa2ef2a825e2676aaff537c54eda62ad97ee11a631dc5328648614bfdd69b6dca744c7377fea9fef3b5cc0c4d3341ad511adb4be'
7
+ data.tar.gz: a820d50b02742e1b8a7c79fe9075f48e6ae7c90f3551dba8e8fbb37c7ed28d2e7089ac9fe81b82c2db7e8bf7d20f926840084d841e30bb3d2a733efa68e2892f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
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
+
5
15
  ## 1.11.0 (2026-09-04)
6
16
 
7
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.
@@ -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
- File.write(manifest_path_value, insert_manifest_statement(manifest_text, declaration))
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
- File.write(manifest_path_value, remove_manifest_statement(manifest_text, name))
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 = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
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] [--major] [--latest] [--dry-run] [--json]",
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] [--major] [--latest] [--dry-run] [--json]
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
- dispatch(command)
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 "toml-rb"
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 = TomlRB.load_file(path)
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 TomlRB::ParseError => error
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 "toml-rb"
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 = TomlRB.parse(text)
128
+ data = PerfectTOML.parse(text)
129
129
  from_hash(data)
130
- rescue TomlRB::ParseError => error
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
- File.write(path, serialize_lockfile(lockfile))
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
- File.write(path, serialized)
156
+ Transaction.write_file(path, serialized)
157
157
  end
158
158
 
159
159
  def lockfiles_equivalent?(left, right)
@@ -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 = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
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 = Pray.read_lockfile(lockfile_path)
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 = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
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/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, lockfile_path, previous_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(lockfile_path, lockfile),
17
+ lockfile: lockfile_change_status(previous_lockfile, lockfile),
17
18
  targets: rendered.map { |target| target_change(project, target) },
18
- provisioned: Render.planned_provisioned_files(project).map do |file|
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(lockfile_path, lockfile)
55
- return "create" unless File.exist?(lockfile_path)
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 File.read(path) == target.content
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
- rendered.each do |target|
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(destination_root, relative)
308
+ destination = Pathname(File.join(relative_root, relative)).cleanpath.to_s
318
309
  planned << PlannedProvisionedFile.new(
319
- path: relative_project_path(project, destination),
310
+ path: destination,
320
311
  source: source,
321
312
  package: package_name,
322
313
  export: export_name
@@ -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 = previous_map(previous_lockfile)[file.path.to_s.tr("\\", "/")]
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 provisioned and then edited")
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}`; it already exists and is not the expected provisioned file"
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.read, display)
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.read
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 provisioned and then edited")
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.read }
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
 
@@ -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 "toml-rb"
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(TomlRB.load_file(path))
48
- rescue TomlRB::ParseError => error
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
 
@@ -44,7 +44,7 @@ module Pray
44
44
  end
45
45
 
46
46
  def parse_compromised_toml(body)
47
- data = TomlRB.parse(body)
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 TomlRB::ParseError
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 = File.read(absolute_path)
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 = File.binread(absolute)
27
+ destination_bytes = RenderDest.read_regular_bytes(absolute, path_text)
27
28
  expected_bytes = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
28
- next if Hashing.sha256_prefixed(destination_bytes) == Hashing.sha256_prefixed(expected_bytes.b)
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}`. Run `pray install` to restore it."
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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pray
4
- VERSION = "1.11.0"
5
- GENERATED_BY = "pray 1.11.0"
4
+ VERSION = "1.12.0"
5
+ GENERATED_BY = "pray 1.12.0"
6
6
  end
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 "toml-rb", "~> 4.0"
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.11.0
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: toml-rb
13
+ name: perfect_toml
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '4.0'
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: '4.0'
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
@@ -166,6 +172,10 @@ files:
166
172
  - lib/pray/sync.rb
167
173
  - lib/pray/tar_validation.rb
168
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
169
179
  - lib/pray/trust.rb
170
180
  - lib/pray/trust_feed.rb
171
181
  - lib/pray/trust_ops.rb