ace-assign 0.64.0 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +25 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +36 -18
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +5 -2
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +243 -12
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +44 -12
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +12 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +36 -5
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +90 -11
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -15,10 +15,10 @@ module Ace
|
|
|
15
15
|
# metadata only.
|
|
16
16
|
module EvidenceEvent
|
|
17
17
|
TYPES = %w[
|
|
18
|
-
intent process_start receipt_accepted transition
|
|
18
|
+
intent process_start receipt_accepted transition attempt_stopped
|
|
19
19
|
candidate_invalidated reconciliation
|
|
20
|
-
service_claim service_transition recovery_observation inbox_binding
|
|
21
|
-
authority_mutation evidence_import proposal_state result_submitted
|
|
20
|
+
service_claim service_transition service_challenge service_no_effect_challenge service_publication_challenge service_publication_issuing recovery_observation inbox_binding delivery
|
|
21
|
+
authority_mutation evidence_import proposal_state result_submitted prompt_issued prompt_outcome prompt_completion_observed input_inhibited
|
|
22
22
|
scope_provisioning scope_bound scope_native_bound scope_child_bound scope_sealed scope_closed_no_writers
|
|
23
23
|
].freeze
|
|
24
24
|
|
|
@@ -13,7 +13,8 @@ module Ace
|
|
|
13
13
|
# Uses ace-b36ts for assignment ID generation.
|
|
14
14
|
class AssignmentManager
|
|
15
15
|
# @param cache_base [String] Base cache directory
|
|
16
|
-
def initialize(cache_base: nil)
|
|
16
|
+
def initialize(cache_base: nil, selected_assignment: nil)
|
|
17
|
+
@selected_assignment = selected_assignment
|
|
17
18
|
@cache_base = cache_base || Ace::Assign.cache_dir
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -85,6 +86,7 @@ module Ace
|
|
|
85
86
|
#
|
|
86
87
|
# @return [Models::Assignment, nil] Most recent assignment or nil
|
|
87
88
|
def find_active
|
|
89
|
+
return @selected_assignment if @selected_assignment
|
|
88
90
|
return nil unless File.directory?(@cache_base)
|
|
89
91
|
|
|
90
92
|
# Priority 1: use .current symlink if it exists (explicit selection)
|
|
@@ -185,6 +187,7 @@ module Ace
|
|
|
185
187
|
)
|
|
186
188
|
|
|
187
189
|
write_assignment_file(updated)
|
|
190
|
+
@selected_assignment = updated if @selected_assignment && @selected_assignment.id == updated.id
|
|
188
191
|
|
|
189
192
|
# Update .latest symlink since this assignment was just updated
|
|
190
193
|
update_latest_symlink(assignment.id)
|
|
@@ -259,7 +262,14 @@ module Ace
|
|
|
259
262
|
|
|
260
263
|
def write_assignment_file(assignment)
|
|
261
264
|
assignment_file = File.join(assignment.cache_dir, "assignment.yaml")
|
|
262
|
-
|
|
265
|
+
if @selected_assignment
|
|
266
|
+
File.open(assignment_file, File::WRONLY | File::TRUNC | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
267
|
+
raise ArgumentError, "selected assignment metadata is not regular" unless file.stat.file?
|
|
268
|
+
file.write(assignment.to_h.to_yaml); file.flush; file.fsync
|
|
269
|
+
end
|
|
270
|
+
else
|
|
271
|
+
File.write(assignment_file, assignment.to_h.to_yaml)
|
|
272
|
+
end
|
|
263
273
|
end
|
|
264
274
|
|
|
265
275
|
def load_from_file(assignment_file)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
require_relative "canonical_attempt_state"
|
|
2
3
|
|
|
3
4
|
require "json"
|
|
4
5
|
|
|
@@ -19,6 +20,21 @@ module Ace
|
|
|
19
20
|
# effects are never retried automatically, and an uncertain attempt
|
|
20
21
|
# without a receipt stays uncertain.
|
|
21
22
|
class AttemptReconciler
|
|
23
|
+
# The protected boundary supplies authenticated original scope facts
|
|
24
|
+
# and exact kernel identities; never a runtime discovered by pane name.
|
|
25
|
+
def self.protected_observation(kernel:, binding:, observer:, lineage:)
|
|
26
|
+
return {"liveness" => "unknown"} if lineage.sealed?
|
|
27
|
+
kernel.live!(binding.fetch("process_identity"))
|
|
28
|
+
kernel.live!(binding.fetch("native_origin").fetch("server_identity"))
|
|
29
|
+
observation = observer.observe(lineage)
|
|
30
|
+
unless observation.is_a?(Hash) && observation["populated"].is_a?(Integer) && observation["populated"].positive?
|
|
31
|
+
return {"liveness" => "unknown"}
|
|
32
|
+
end
|
|
33
|
+
{"liveness" => "live", "identity" => binding.fetch("process_identity"), "runtime_binding" => binding}
|
|
34
|
+
rescue AttemptErrors::EvidenceUnavailable, Ace::Runtime::RuntimeUnavailableError
|
|
35
|
+
{"liveness" => "unknown"}
|
|
36
|
+
end
|
|
37
|
+
|
|
22
38
|
# @param journal [EvidenceJournal, nil] Managed evidence journal
|
|
23
39
|
# @param verifier [ReceiptVerifier, nil] Receipt verifier (coordinator supplies on use)
|
|
24
40
|
def initialize(journal: nil, verifier: nil, observer: nil, runtime_resolver: Ace::Runtime)
|
|
@@ -33,6 +49,10 @@ module Ace
|
|
|
33
49
|
# @param attempt [Models::Attempt] Running attempt
|
|
34
50
|
# @return [Symbol] :stopped, :live, or :uncertain
|
|
35
51
|
def classify(attempt)
|
|
52
|
+
events = events_for(attempt)
|
|
53
|
+
if events.any? { |event| event["type"] == "attempt_stopped" } && CanonicalAttemptState.derive(events) == "stopped"
|
|
54
|
+
return :stopped
|
|
55
|
+
end
|
|
36
56
|
return :live if process_live?(attempt)
|
|
37
57
|
:uncertain
|
|
38
58
|
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "execution_scope_lineage"
|
|
4
|
+
require_relative "../atoms/attempt_state_machine"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# Canonical state projection, not live closure or settlement authority.
|
|
10
|
+
# The stopped producer and terminal consumer must additionally obtain the
|
|
11
|
+
# existing owners' authenticated original-prefix evidence and fresh proof.
|
|
12
|
+
module CanonicalAttemptState
|
|
13
|
+
STOPPED_FIELDS = %w[project_id mapping_id assignment_id attempt_id descriptor_sha256
|
|
14
|
+
scope_binding_event_id seal_event_id closed_proof_event_id service_settlement_event_digests].freeze
|
|
15
|
+
SELECTION_FIELDS = (STOPPED_FIELDS - %w[service_settlement_event_digests]).freeze
|
|
16
|
+
|
|
17
|
+
def self.derive(events)
|
|
18
|
+
return nil unless events.any? { |event| event["type"] == "intent" }
|
|
19
|
+
state = "reserved"
|
|
20
|
+
stopped = false
|
|
21
|
+
events.each do |event|
|
|
22
|
+
if stopped && %w[process_start receipt_accepted transition reconciliation attempt_stopped].include?(event["type"])
|
|
23
|
+
unavailable!("Accepted stopped attempt cannot be resurrected or replaced")
|
|
24
|
+
end
|
|
25
|
+
case event["type"]
|
|
26
|
+
when "process_start" then state = "running"
|
|
27
|
+
when "receipt_accepted" then state = event.dig("payload", "receipt", "verdict") || state
|
|
28
|
+
when "transition" then state = event.dig("payload", "to") || state
|
|
29
|
+
when "reconciliation" then state = event.dig("payload", "resolution") || state
|
|
30
|
+
when "attempt_stopped"
|
|
31
|
+
verify_stopped_acceptance!(events, event, prior_state: state)
|
|
32
|
+
state = "stopped"
|
|
33
|
+
stopped = true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.stopped_payload!(events:, selection:, service_evidence:, commit:)
|
|
40
|
+
unless selection.is_a?(Hash) && selection.keys.sort == SELECTION_FIELDS.sort &&
|
|
41
|
+
service_evidence.is_a?(Hash) && service_evidence.keys.sort == %w[commit services] &&
|
|
42
|
+
commit.is_a?(String) && commit.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
43
|
+
service_evidence["commit"] == commit
|
|
44
|
+
unavailable!("Stopped plan evidence prefix differs")
|
|
45
|
+
end
|
|
46
|
+
services = service_evidence.fetch("services")
|
|
47
|
+
unless services.is_a?(Array) &&
|
|
48
|
+
services.all? { |item| item.is_a?(Hash) && item.keys.sort == %w[event_digest evidence_refs receipt_digest record_digest request_id state] }
|
|
49
|
+
unavailable!("Stopped settlement projections are malformed")
|
|
50
|
+
end
|
|
51
|
+
service_ids = services.map { |item| item.fetch("request_id") }
|
|
52
|
+
unless service_ids == service_ids.sort && service_ids.uniq == service_ids
|
|
53
|
+
unavailable!("Stopped settlement projection is not ordered unique owner output")
|
|
54
|
+
end
|
|
55
|
+
services.each do |item|
|
|
56
|
+
event = events.find { |entry| entry["digest"] == item.fetch("event_digest") && entry["type"] == "service_transition" }
|
|
57
|
+
unless event && event.dig("payload", "request_id") == item.fetch("request_id") &&
|
|
58
|
+
event.dig("payload", "state") == item.fetch("state") && %w[succeeded failed-settled].include?(item.fetch("state")) &&
|
|
59
|
+
event.dig("payload", "record_digest") == item.fetch("record_digest") &&
|
|
60
|
+
event.dig("payload", "receipt_digest") == item.fetch("receipt_digest")
|
|
61
|
+
unavailable!("Stopped service projection differs from canonical terminal event")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
payload = selection.merge("service_settlement_event_digests" => services.map { |item| item.fetch("event_digest") }.sort)
|
|
65
|
+
validate_stopped_payload!(events, payload)
|
|
66
|
+
payload
|
|
67
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
68
|
+
unavailable!("Stopped plan evidence is malformed")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.verify_stopped_acceptance!(events, event, prior_state:)
|
|
72
|
+
Atoms::AttemptStateMachine.proof_stopped_transition!(prior_state)
|
|
73
|
+
unless events.count { |entry| entry["type"] == "attempt_stopped" } == 1 && Models::EvidenceEvent.chain_valid?(events)
|
|
74
|
+
unavailable!("Stopped canonical chain is ambiguous or corrupt")
|
|
75
|
+
end
|
|
76
|
+
payload = event.fetch("payload")
|
|
77
|
+
prefix = events.take_while { |entry| entry["digest"] != event.fetch("digest") }
|
|
78
|
+
validate_stopped_payload!(prefix, payload)
|
|
79
|
+
acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
|
|
80
|
+
data = acceptance&.dig("payload", "data")
|
|
81
|
+
generation = acceptance && events.take_while { |entry| entry["digest"] != acceptance["digest"] }.count { |entry| entry["type"] == "authority_mutation" } + 1
|
|
82
|
+
unless acceptance && data.is_a?(Hash) && data.keys.sort == %w[attempt_id generation proof_id required_action state] &&
|
|
83
|
+
data["generation"].is_a?(Integer) && data["generation"] == generation &&
|
|
84
|
+
acceptance.dig("payload", "operation") == "stop_attempt" &&
|
|
85
|
+
acceptance.dig("payload", "assignment_id") == payload.fetch("assignment_id") &&
|
|
86
|
+
acceptance.dig("payload", "attempt_id") == payload.fetch("attempt_id") &&
|
|
87
|
+
acceptance.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
|
|
88
|
+
acceptance.dig("payload", "data", "state") == "stopped" &&
|
|
89
|
+
acceptance.dig("payload", "data", "proof_id") == payload.fetch("closed_proof_event_id") &&
|
|
90
|
+
acceptance.dig("payload", "data", "required_action").nil?
|
|
91
|
+
unavailable!("Stopped transition has no adjacent accepted owner mutation")
|
|
92
|
+
end
|
|
93
|
+
seal = prefix.find { |entry| entry["digest"] == payload.fetch("seal_event_id") }
|
|
94
|
+
accepted_seal = seal && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == seal["digest"] &&
|
|
95
|
+
%w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) }
|
|
96
|
+
proof = prefix.find { |entry| entry["digest"] == payload.fetch("closed_proof_event_id") }
|
|
97
|
+
accepted_proof = proof && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == proof["digest"] &&
|
|
98
|
+
%w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) &&
|
|
99
|
+
entry.dig("payload", "data", "proof_id") == proof["digest"] &&
|
|
100
|
+
(entry.dig("payload", "operation") == "close_execution_scope" ?
|
|
101
|
+
entry.dig("payload", "data", "state") == "closed_no_writers" :
|
|
102
|
+
entry.dig("payload", "data").is_a?(Hash) && entry.dig("payload", "data").keys.sort == %w[attempt_id generation proof_id required_action state] &&
|
|
103
|
+
entry.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
|
|
104
|
+
entry.dig("payload", "data", "generation").is_a?(Integer) &&
|
|
105
|
+
entry.dig("payload", "data", "generation") == prefix.take_while { |item| item["digest"] != entry["digest"] }.count { |item| item["type"] == "authority_mutation" } + 1 &&
|
|
106
|
+
entry.dig("payload", "data", "state") == "uncertain" &&
|
|
107
|
+
entry.dig("payload", "data", "required_action") == "reconcile_scope") }
|
|
108
|
+
unless accepted_seal && (accepted_proof || event["previous_digest"] == proof&.fetch("digest"))
|
|
109
|
+
unavailable!("Stopped canonical seal or proof has no accepted owner")
|
|
110
|
+
end
|
|
111
|
+
event
|
|
112
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::InvalidTransition
|
|
113
|
+
unavailable!("Stopped canonical acceptance is malformed")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.validate_stopped_payload!(events, payload)
|
|
117
|
+
unless payload.is_a?(Hash) && payload.keys.sort == STOPPED_FIELDS.sort &&
|
|
118
|
+
%w[project_id mapping_id assignment_id attempt_id].all? { |key| payload[key].is_a?(String) && ExecutionScopeLineage::ID.match?(payload[key]) } &&
|
|
119
|
+
%w[descriptor_sha256 scope_binding_event_id seal_event_id closed_proof_event_id].all? { |key| digest?(payload[key]) } &&
|
|
120
|
+
%w[service_settlement_event_digests].all? { |key|
|
|
121
|
+
values = payload[key]
|
|
122
|
+
values.is_a?(Array) && values.all? { |value| digest?(value) } && values == values.sort && values.uniq == values }
|
|
123
|
+
unavailable!("Stopped canonical payload is not closed")
|
|
124
|
+
end
|
|
125
|
+
lineage = ExecutionScopeLineage.new(events: events, **payload.slice("project_id", "mapping_id", "assignment_id", "attempt_id").transform_keys(&:to_sym))
|
|
126
|
+
provisioning = events.select { |event| event["type"] == "scope_provisioning" }
|
|
127
|
+
unless provisioning.one? && provisioning.first.dig("payload", "descriptor_sha256") == payload.fetch("descriptor_sha256") &&
|
|
128
|
+
lineage.native_event && lineage.child_event &&
|
|
129
|
+
lineage.binding_event&.fetch("digest") == payload.fetch("scope_binding_event_id") &&
|
|
130
|
+
lineage.seal_event&.fetch("digest") == payload.fetch("seal_event_id") &&
|
|
131
|
+
lineage.proof_event&.fetch("digest") == payload.fetch("closed_proof_event_id")
|
|
132
|
+
unavailable!("Stopped canonical original scope selectors differ")
|
|
133
|
+
end
|
|
134
|
+
payload.fetch("service_settlement_event_digests").each do |digest|
|
|
135
|
+
unless events.any? { |event| event["digest"] == digest && event["type"] == "service_transition" &&
|
|
136
|
+
%w[succeeded failed-settled].include?(event.dig("payload", "state")) }
|
|
137
|
+
unavailable!("Stopped canonical service selector is not terminal evidence")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
payload
|
|
141
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
142
|
+
unavailable!("Stopped canonical scope is malformed")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.digest?(value) = value.is_a?(String) && ExecutionScopeLineage::DIGEST.match?(value)
|
|
146
|
+
def self.unavailable!(message) = raise AttemptErrors::EvidenceUnavailable, message
|
|
147
|
+
private_class_method :digest?, :unavailable!
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -14,8 +14,8 @@ module Ace
|
|
|
14
14
|
MAX_TOTAL_BYTES = 256 * 1024
|
|
15
15
|
MAX_ARTIFACTS = 16
|
|
16
16
|
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
17
|
-
ROLES = {"service" => "executor", "review" => "reviewer",
|
|
18
|
-
"
|
|
17
|
+
ROLES = {"service" => "executor", "review" => "reviewer",
|
|
18
|
+
"result" => "worker"}.freeze
|
|
19
19
|
DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
|
|
20
20
|
binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
|
|
21
21
|
candidate_generation_or_claim_generation].freeze
|
|
@@ -132,6 +132,9 @@ module Ace
|
|
|
132
132
|
expected.all? { |key, value| descriptor[key] == value }
|
|
133
133
|
before = events.take_while { |entry| entry["digest"] != event["digest"] }
|
|
134
134
|
unavailable! unless before.any? { |entry| entry["digest"] == descriptor["admitted_after_event_digest"] }
|
|
135
|
+
if kind == "service" && binding["no_effect_challenge"]
|
|
136
|
+
unavailable! unless descriptor["admitted_after_event_digest"] == binding.fetch("challenge_event_digest")
|
|
137
|
+
end
|
|
135
138
|
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
136
139
|
bytes = yield(reference.fetch("ref"))
|
|
137
140
|
unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
|
|
@@ -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
|