ace-assign 0.63.1 → 0.66.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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "ace/herdr/molecules/bounded_process"
7
+ require_relative "evidence_journal"
8
+ require_relative "../authority/private_directory"
9
+
10
+ module Ace
11
+ module Assign
12
+ module Molecules
13
+ # Isolates read-only Git from an installed authority's live configuration.
14
+ # The copied view never publishes evidence or mutates the source journal.
15
+ class CanonicalReadSnapshot
16
+ class Unavailable < AttemptErrors::EvidenceUnavailable; end
17
+ BYTE_LIMIT = 256 * 1_048_576
18
+ ENTRY_LIMIT = 4096
19
+ OID = /\A[0-9a-f]{40}\z/
20
+ ENVIRONMENT = {"PATH" => "/usr/bin:/bin", "LC_ALL" => "C",
21
+ "GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_SYSTEM" => "/dev/null",
22
+ "GIT_CONFIG_GLOBAL" => "/dev/null", "GIT_TERMINAL_PROMPT" => "0",
23
+ "GIT_ALLOW_PROTOCOL" => "", "GIT_NO_REPLACE_OBJECTS" => "1",
24
+ "GIT_NO_LAZY_FETCH" => "1", "GIT_OPTIONAL_LOCKS" => "0"}.freeze
25
+
26
+ # These selections come from installed source composition, not frames.
27
+ def initialize(repo_root:, checkout_root:, ref:, private_root:, git_executable: "/usr/bin/git")
28
+ paths = [repo_root, checkout_root, private_root, git_executable]
29
+ unless paths.all? { |path| path.is_a?(String) && path.start_with?("/") &&
30
+ !path.include?("\0") && File.expand_path(path) == path } &&
31
+ ref.is_a?(String) && ref.match?(/\Arefs\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)+\z/)
32
+ raise ArgumentError, "canonical snapshot source selection is malformed"
33
+ end
34
+ @repo_root, @checkout_root, @private_root, @git = paths.map { |path| path.dup.freeze }
35
+ @ref = ref.dup.freeze
36
+ @verification = Mutex.new
37
+ end
38
+
39
+ def with(deadline:)
40
+ refuse!("snapshot_busy") unless @verification.try_lock
41
+ begin
42
+ with_snapshot(deadline: deadline) { |snapshot| yield snapshot }
43
+ ensure
44
+ @verification.unlock
45
+ end
46
+ end
47
+
48
+ def with_snapshot(deadline:)
49
+ validate_deadline!(deadline)
50
+ verify_private_root!
51
+ private_stat = directory!(@private_root)
52
+ unless private_stat.uid == Process.uid && (private_stat.mode & 0o7777) == 0o700
53
+ refuse!("private_storage_unprotected")
54
+ end
55
+ Dir.mktmpdir("canonical-read-", @private_root) do |view|
56
+ verify_private_root!
57
+ refuse!("private_storage_unprotected") unless directory_identity(File.lstat(@private_root)) == directory_identity(private_stat)
58
+ @view, @deadline = view, deadline
59
+ @held, @directories, @inventory, @inventory_files, @metadata_files = [], [], [], [], []
60
+ @bytes, @entries = 0, 0
61
+ git_dir = File.join(@repo_root, ".git")
62
+ directory!(git_dir)
63
+ %w[commondir reftable shallow].each do |name|
64
+ refuse!("unsupported_source") if exists?(File.join(git_dir, name))
65
+ end
66
+ lock, original_lock = held_file!(File.join(@checkout_root, ".evidence.lock"), limit: 128)
67
+ begin
68
+ acquire!(lock)
69
+ begin
70
+ source_config!(git_dir)
71
+ @commit = select_ref!(git_dir)
72
+ objects = File.join(git_dir, "objects")
73
+ inventory = inventory!(objects)
74
+ inventory.each { |relative, handle| copy!(relative, handle) }
75
+ verify_held!
76
+ refuse!("source_changed") unless inventory_names!(objects) == @inventory &&
77
+ select_ref!(git_dir) == @commit && identity(File.lstat(lock.path)) == original_lock
78
+ verify_held!
79
+ ensure
80
+ lock.flock(File::LOCK_UN)
81
+ end
82
+ end
83
+ prepare_view!
84
+ run!(%w[fsck --full --strict --no-reflogs], limit: 65_536)
85
+ yield self
86
+ ensure
87
+ @held&.each { |handle| handle.close unless handle.closed? }
88
+ @held = @directories = @inventory = @inventory_files = @metadata_files = @view = @deadline = @commit = nil
89
+ end
90
+ rescue Timeout::Error
91
+ refuse!("resource_limit")
92
+ rescue IOError, SystemCallError, Herdr::Molecules::BoundedProcess::PostLaunchError
93
+ refuse!("source_unavailable")
94
+ end
95
+ private :with_snapshot
96
+
97
+ attr_reader :commit
98
+
99
+ # EvidenceJournal uses this only as its existing read command boundary.
100
+ def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
101
+ refuse!("read_outside_snapshot") unless @view && @commit
102
+ unless read_command?(argv, stdin_data)
103
+ refuse!("write_or_unsupported_command")
104
+ end
105
+ result = run!(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
106
+ [result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
107
+ end
108
+
109
+ def blob_batch(entries, output_limit:)
110
+ refuse!("read_outside_snapshot") unless @view && @commit
111
+ unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
112
+ refuse!("unsupported_command")
113
+ end
114
+ result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
115
+ limit: [output_limit, BYTE_LIMIT].min)
116
+ result.stdout.to_s.b
117
+ end
118
+
119
+ def history_diff(commits, paths, output_limit:)
120
+ refuse!("read_outside_snapshot") unless @view && @commit
121
+ unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
122
+ commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
123
+ paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
124
+ (paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
125
+ output_limit.is_a?(Integer) && output_limit.positive?
126
+ refuse!("unsupported_command")
127
+ end
128
+ result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
129
+ --no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
130
+ limit: [output_limit, BYTE_LIMIT].min)
131
+ result.stdout.to_s.b
132
+ end
133
+
134
+ def blob_sizes(oids)
135
+ refuse!("read_outside_snapshot") unless @view && @commit
136
+ unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
137
+ refuse!("unsupported_command")
138
+ end
139
+ run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
140
+ end
141
+
142
+ def blob(path, commit:, max_bytes:)
143
+ refuse!("read_outside_snapshot") unless @view && @commit
144
+ unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
145
+ refuse!("unsupported_command")
146
+ end
147
+ result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
148
+ result.stdout.to_s.b
149
+ end
150
+
151
+ private
152
+
153
+ def refuse!(reason)
154
+ raise Unavailable, "canonical read snapshot unavailable: #{reason}"
155
+ end
156
+
157
+ def validate_deadline!(deadline)
158
+ unless deadline.is_a?(Numeric) && deadline.finite? && deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ refuse!("resource_limit")
160
+ end
161
+ end
162
+
163
+ def remaining
164
+ value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ refuse!("resource_limit") unless value.positive?
166
+ value
167
+ end
168
+
169
+ def acquire!(lock)
170
+ until lock.flock(File::LOCK_SH | File::LOCK_NB)
171
+ sleep [remaining, 0.005].min
172
+ end
173
+ end
174
+
175
+ def identity(stat)
176
+ [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid, stat.size, stat.mtime, stat.ctime]
177
+ end
178
+
179
+ def verify_private_root!
180
+ Authority::PrivateDirectory.verify!(@private_root)
181
+ rescue AttemptErrors::ReceiptRejected
182
+ refuse!("private_storage_unprotected")
183
+ end
184
+
185
+ def directory_identity(stat)
186
+ [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid]
187
+ end
188
+
189
+ def directory!(path)
190
+ stat = File.lstat(path)
191
+ refuse!("unsupported_source") unless stat.directory? && !stat.symlink?
192
+ stat
193
+ end
194
+
195
+ def exists?(path)
196
+ File.lstat(path)
197
+ true
198
+ rescue Errno::ENOENT
199
+ false
200
+ end
201
+
202
+ def held_file!(path, limit:)
203
+ remaining
204
+ parent = File.dirname(path)
205
+ until parent == "/"
206
+ stat = directory!(parent)
207
+ @directories << [parent, directory_identity(stat)]
208
+ parent = File.dirname(parent)
209
+ end
210
+ handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
211
+ @held << handle
212
+ stat = handle.stat
213
+ refuse!("unsupported_source") unless stat.file?
214
+ refuse!("resource_limit") if stat.size > limit
215
+ [handle, identity(stat)]
216
+ end
217
+
218
+ def select_ref!(git_dir)
219
+ loose = File.join(git_dir, @ref)
220
+ if exists?(loose)
221
+ handle, before = held_file!(loose, limit: 128)
222
+ bytes = handle.read(129)
223
+ @metadata_files << [loose, handle, before]
224
+ refuse!("source_changed") unless identity(handle.stat) == before
225
+ refuse!("unsupported_ref") unless bytes.match?(/\A[0-9a-f]{40}\n?\z/)
226
+ return bytes.strip
227
+ end
228
+ packed = File.join(git_dir, "packed-refs")
229
+ handle, before = held_file!(packed, limit: 65_536)
230
+ bytes = handle.read(65_537)
231
+ @metadata_files << [packed, handle, before]
232
+ refuse!("source_changed") unless identity(handle.stat) == before
233
+ entries = {}
234
+ bytes.lines.each do |line|
235
+ next if line.start_with?("#") || line.match?(/\A\^[0-9a-f]{40}\n?\z/)
236
+ match = /\A([0-9a-f]{40}) (refs\/[^\s\0]+)\n?\z/.match(line)
237
+ refuse!("unsupported_ref") unless match && !entries.key?(match[2])
238
+ entries[match[2]] = match[1]
239
+ end
240
+ entries.fetch(@ref) { refuse!("unsupported_ref") }
241
+ end
242
+
243
+ def source_config!(git_dir)
244
+ path = File.join(git_dir, "config")
245
+ handle, before = held_file!(path, limit: 65_536)
246
+ bytes = handle.read(65_537)
247
+ @metadata_files << [path, handle, before]
248
+ refuse!("source_changed") unless identity(handle.stat) == before
249
+ # Standalone file parsing does not select the target repository,
250
+ # follow includes or execute aliases/hooks/helpers. No source config
251
+ # is installed into the private administrative view.
252
+ result = run!(["config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"],
253
+ stdin_data: bytes, limit: 65_536)
254
+ versions = []
255
+ result.stdout.split("\0").each do |entry|
256
+ key, value = entry.split("\n", 2)
257
+ refuse!("unsupported_source") if key.start_with?("extensions.") || key == "core.worktree"
258
+ versions << value if key == "core.repositoryformatversion"
259
+ end
260
+ refuse!("unsupported_source") unless versions.empty? || versions == ["0"]
261
+ end
262
+
263
+ def inventory_names!(objects)
264
+ names = []
265
+ walk = lambda do |path, relative|
266
+ remaining
267
+ stat = File.lstat(path)
268
+ names << [relative, stat.directory? ? "directory" : "file"]
269
+ refuse!("resource_limit") if names.size > ENTRY_LIMIT
270
+ refuse!("unsupported_source") if stat.symlink? || !(stat.directory? || stat.file?)
271
+ if stat.directory?
272
+ unless relative.empty? || %w[pack info].include?(relative) || relative.match?(/\A[0-9a-f]{2}\z/)
273
+ refuse!("unsupported_source")
274
+ end
275
+ children = []
276
+ Dir.each_child(path) do |name|
277
+ remaining
278
+ children << name
279
+ refuse!("resource_limit") if children.size + names.size > ENTRY_LIMIT
280
+ end
281
+ children.sort.each { |name| walk.call(File.join(path, name), relative.empty? ? name : "#{relative}/#{name}") }
282
+ end
283
+ end
284
+ walk.call(objects, "")
285
+ names
286
+ end
287
+
288
+ def inventory!(objects)
289
+ @inventory = inventory_names!(objects)
290
+ files = @inventory.select { |_relative, kind| kind == "file" }.map(&:first)
291
+ packs = files.grep(/\Apack\/pack-[0-9a-f]{40}\.pack\z/)
292
+ unless packs.all? { |pack| files.include?(pack.sub(/\.pack\z/, ".idx")) } &&
293
+ files.grep(/\Apack\/pack-[0-9a-f]{40}\.(?:idx|rev)\z/).all? { |file| packs.include?(file.sub(/\.(?:idx|rev)\z/, ".pack")) }
294
+ refuse!("unsupported_source")
295
+ end
296
+ @inventory.filter_map do |relative, kind|
297
+ path = relative.empty? ? objects : File.join(objects, relative)
298
+ if kind == "directory"
299
+ unless relative.empty? || relative == "pack" || relative == "info" || relative.match?(/\A[0-9a-f]{2}\z/)
300
+ refuse!("unsupported_source")
301
+ end
302
+ @directories << [path, directory_identity(File.lstat(path))]
303
+ next
304
+ end
305
+ # These ordinary Git indexes/retention hints do not redirect
306
+ # storage. The clean view never reads them; only object bytes and
307
+ # pack indexes are copied. Redirect/lazy-fetch metadata refuses.
308
+ next if relative.match?(/\Ainfo\/(?:packs|commit-graph)\z/) ||
309
+ relative.match?(/\Apack\/pack-[0-9a-f]{40}\.(?:bitmap|keep)\z/)
310
+ unless relative.match?(/\A[0-9a-f]{2}\/[0-9a-f]{38}\z/) ||
311
+ relative.match?(/\Apack\/pack-[0-9a-f]{40}\.(?:pack|idx|rev)\z/)
312
+ refuse!("unsupported_source")
313
+ end
314
+ handle, before = held_file!(path, limit: BYTE_LIMIT)
315
+ @bytes += handle.stat.size
316
+ refuse!("resource_limit") if @bytes > BYTE_LIMIT
317
+ @inventory_files << [path, handle, before]
318
+ [relative, handle]
319
+ end
320
+ end
321
+
322
+ def copy!(relative, source)
323
+ destination = File.join(@view, "objects", relative)
324
+ FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
325
+ File.open(destination, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |target|
326
+ copied = 0
327
+ expected = source.stat.size
328
+ while (chunk = source.read(65_536))
329
+ remaining
330
+ copied += chunk.bytesize
331
+ refuse!("source_changed") if copied > expected
332
+ target.write(chunk)
333
+ end
334
+ refuse!("source_changed") unless copied == expected
335
+ end
336
+ end
337
+
338
+ def verify_held!
339
+ @directories.each { |path, before| refuse!("source_changed") unless directory_identity(File.lstat(path)) == before }
340
+ (@inventory_files + @metadata_files).each do |path, handle, before|
341
+ refuse!("source_changed") unless identity(handle.stat) == before && identity(File.lstat(path)) == before
342
+ end
343
+ end
344
+
345
+ def prepare_view!
346
+ FileUtils.mkdir_p(File.join(@view, "objects"), mode: 0o700)
347
+ FileUtils.mkdir_p(File.join(@view, "refs"), mode: 0o700)
348
+ File.binwrite(File.join(@view, "HEAD"), "#{@commit}\n")
349
+ File.binwrite(File.join(@view, "config"), "[core]\nrepositoryformatversion = 0\nbare = true\n")
350
+ ref = File.join(@view, @ref)
351
+ FileUtils.mkdir_p(File.dirname(ref), mode: 0o700)
352
+ File.binwrite(ref, "#{@commit}\n")
353
+ end
354
+
355
+ def read_command?(argv, stdin)
356
+ return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
357
+ allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
358
+ "rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
359
+ "show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
360
+ flags = allowed[argv.first]
361
+ flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
362
+ end
363
+
364
+ def run!(argv, stdin_data: "", limit:)
365
+ result = Herdr::Molecules::BoundedProcess.call([@git, "--git-dir=#{@view}", "--no-pager", *argv],
366
+ environment: ENVIRONMENT, chdir: @view, stdin_data: stdin_data,
367
+ timeout_s: remaining, output_limit: limit, stderr_limit: 65_536)
368
+ refuse!(result.oversized ? "resource_limit" : "git_unavailable") unless result.status.success? && !result.oversized
369
+ result
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end