ace-assign 0.63.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +51 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +119 -6
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class LaunchLifecycle
|
|
7
|
+
SCOPE_ABORT_FIELDS = %w[kind scope_generation scope_binding_event_id seal_event_id proof_id].freeze
|
|
8
|
+
|
|
9
|
+
# Also used by the shared final release-before-reuse predicate. Calls
|
|
10
|
+
# existing owners at the same immutable CAS input, without impersonating
|
|
11
|
+
# a public evidence-fetch peer or inventing an alternate verifier.
|
|
12
|
+
def scope_settlement_complete!(journal:, events:, params:, map:, commit:)
|
|
13
|
+
if @result_owner
|
|
14
|
+
@result_owner.service_settlement_complete!(journal: journal, events: events, params: params, map: map, commit: commit)
|
|
15
|
+
else
|
|
16
|
+
service_events = events.any? { |event| %w[service_claim service_transition].include?(event["type"]) }
|
|
17
|
+
service_records = journal.service_request_records(commit: commit).any? do |record|
|
|
18
|
+
record["assignment_id"] == params.fetch("assignment_id") && record["attempt_id"] == params.fetch("attempt_id")
|
|
19
|
+
end
|
|
20
|
+
if service_events || service_records
|
|
21
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical service settlement owner is unavailable"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
contexts = @deployment.project(map.fetch("project_id")).fetch("inbox_contexts", {})
|
|
25
|
+
if contexts.any? || events.any? { |event| event["type"].start_with?("inbox_") }
|
|
26
|
+
unless @result_owner && @result_owner.respond_to?(:inbox_settlement_complete!)
|
|
27
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical inbox settlement owner is unavailable"
|
|
28
|
+
end
|
|
29
|
+
@result_owner.inbox_settlement_complete!(journal: journal, events: events, params: params, map: map, commit: commit)
|
|
30
|
+
end
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def scope_abort_plan(params, map, events, peer, journal:, commit:, supervisor:)
|
|
37
|
+
bytes = params.fetch("failure_evidence").dup.force_encoding(Encoding::UTF_8)
|
|
38
|
+
raise ArgumentError, "scope failure must be UTF-8 JSON" unless bytes.valid_encoding?
|
|
39
|
+
failure = JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
|
|
40
|
+
strict!(failure, SCOPE_ABORT_FIELDS)
|
|
41
|
+
unless failure["kind"] == "protected_scope_before_release" && failure["scope_generation"].is_a?(Integer) && failure["scope_generation"].positive?
|
|
42
|
+
raise ArgumentError, "scope failure selectors differ"
|
|
43
|
+
end
|
|
44
|
+
%w[scope_binding_event_id seal_event_id proof_id].each do |key|
|
|
45
|
+
value = failure.fetch(key)
|
|
46
|
+
raise ArgumentError, "invalid scope failure selector" unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
47
|
+
end
|
|
48
|
+
state = states(events)[params.fetch("attempt_id")]
|
|
49
|
+
unless state && state["mapping_id"] == params.fetch("mapping_id") && state["launch_ticket"] == params.fetch("launch_ticket") &&
|
|
50
|
+
(supervisor || @kernel.same?(state.fetch("launcher_identity"), peer))
|
|
51
|
+
raise AttemptErrors::UnauthorizedIdentity, "scope failure is not owned by this launcher"
|
|
52
|
+
end
|
|
53
|
+
chain = events.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
54
|
+
lineage = Molecules::ExecutionScopeLineage.new(events: chain, project_id: map.fetch("project_id"),
|
|
55
|
+
assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
|
|
56
|
+
lineage.require_positive!(**failure.reject { |key, _| key == "kind" }.transform_keys(&:to_sym))
|
|
57
|
+
scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
|
|
58
|
+
if chain.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" } ||
|
|
59
|
+
state["phase"] == "issued" || state["execution"] == "potentially_executed"
|
|
60
|
+
raise AttemptErrors::Conflict, "scope failure cannot undo issued launch permission"
|
|
61
|
+
end
|
|
62
|
+
scope_settlement_complete!(journal: journal, events: chain, params: params, map: map, commit: commit)
|
|
63
|
+
current = journal.canonical_attempt_state(chain)
|
|
64
|
+
unless %w[reserved running uncertain].include?(current)
|
|
65
|
+
raise AttemptErrors::Conflict, "scope failure cannot change a terminal attempt"
|
|
66
|
+
end
|
|
67
|
+
proof = failure.merge("release" => "not_issued")
|
|
68
|
+
reason = "protected_scope_closed_before_release"
|
|
69
|
+
event = if current == "uncertain"
|
|
70
|
+
{type: "reconciliation", payload: {"resolution" => "failed", "reason" => reason,
|
|
71
|
+
"failure_digest" => params.fetch("failure_digest"), "abort_observation" => proof}}
|
|
72
|
+
else
|
|
73
|
+
# This is the sole reserved -> failed admission. The generic state
|
|
74
|
+
# machine remains unchanged; no synthetic process_start is written.
|
|
75
|
+
{type: "transition", payload: {"from" => current, "to" => "failed", "reason" => reason,
|
|
76
|
+
"abort_observation" => proof}}
|
|
77
|
+
end
|
|
78
|
+
reference = "evidence/imports/launch-failure-#{params.fetch('attempt_id')}-#{params.fetch('failure_digest')}"
|
|
79
|
+
{events: [event], blobs: {reference => params.fetch("failure_evidence")}, data: state.merge(
|
|
80
|
+
"phase" => "failed", "failure_digest" => params.fetch("failure_digest"), "failure_ref" => reference,
|
|
81
|
+
"abort_observation" => proof, "required_action" => nil)}
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
raise ArgumentError, "scope failure JSON is invalid"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class LaunchLifecycle
|
|
7
|
+
# Internal owner call, deliberately absent from the public operation
|
|
8
|
+
# allowlist. The durable winner alone may call the fixed manager. A
|
|
9
|
+
# lost StartUnit reply retains admission uncertainty; replay never starts.
|
|
10
|
+
def admit_native_service!(params:, peer:, role:)
|
|
11
|
+
strict!(params, %w[mapping_id assignment_id attempt_id launch_ticket mutation_id expected_generation])
|
|
12
|
+
unless %i[launcher supervisor].include?(role)
|
|
13
|
+
raise AttemptErrors::UnauthorizedIdentity, "native admission requires an exact launch owner"
|
|
14
|
+
end
|
|
15
|
+
%w[assignment_id attempt_id launch_ticket mutation_id].each { |key| token!(params.fetch(key)) }
|
|
16
|
+
generation!(params.fetch("expected_generation"))
|
|
17
|
+
@kernel.live!(peer)
|
|
18
|
+
map = @deployment.mapping(params.fetch("mapping_id"))
|
|
19
|
+
journal = journal_for(map)
|
|
20
|
+
input = params.reject { |key, _| key == "mutation_id" }
|
|
21
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(input)))
|
|
22
|
+
with_exclusion(params, map, journal) do
|
|
23
|
+
admit_native_service_held!(params, map, journal, peer, role, digest)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def admit_native_service_held!(params, map, journal, peer, role, digest)
|
|
30
|
+
result = @mutex.synchronize do
|
|
31
|
+
# Visibility is current even on a historical mutation replay.
|
|
32
|
+
current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
33
|
+
scope_admission_owner!(params, map, current, peer, role)
|
|
34
|
+
journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
35
|
+
mutation_id: params.fetch("mutation_id"), operation: "scope_service_admission", parameters_digest: digest,
|
|
36
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, _commit, generation|
|
|
37
|
+
state, lineage = scope_admission_owner!(params, map, events, peer, role)
|
|
38
|
+
lineage.require_open!
|
|
39
|
+
unless state["phase"] == "reserved" && !lineage.native_event && !lineage.child_event &&
|
|
40
|
+
events.none? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_service_admission" }
|
|
41
|
+
raise AttemptErrors::Conflict, "native activation was already admitted or launch advanced"
|
|
42
|
+
end
|
|
43
|
+
observer = scope_observer_for(params.fetch("mapping_id"))
|
|
44
|
+
installation = observer.native_admission_ready!(lineage)
|
|
45
|
+
binding = lineage.binding
|
|
46
|
+
data = state.merge("scope_generation" => binding.fetch("scope_generation"),
|
|
47
|
+
"scope_binding_event_id" => lineage.binding_event.fetch("digest"),
|
|
48
|
+
"service_unit" => map.fetch("execution_scope").fetch("service_unit"), "native_admission" => "issued_uncertain",
|
|
49
|
+
"network_installation" => installation)
|
|
50
|
+
unless installation.is_a?(Hash) && installation["namespace_path"] == map.fetch("execution_scope").fetch("network_namespace_path")
|
|
51
|
+
raise AttemptErrors::EvidenceUnavailable, "network verifier output names another fixed namespace path"
|
|
52
|
+
end
|
|
53
|
+
candidate = Models::EvidenceEvent.build(type: "authority_mutation", attempt_id: params.fetch("attempt_id"),
|
|
54
|
+
payload: {"operation" => "scope_service_admission", "data" => data.merge("generation" => generation + 1)},
|
|
55
|
+
previous_digest: events.last&.fetch("digest"))
|
|
56
|
+
Molecules::ExecutionScopeLineage.new(events: events + [candidate], project_id: map.fetch("project_id"),
|
|
57
|
+
assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
|
|
58
|
+
{events: [], blobs: {}, data: data}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
# No authority mutex or journal ref lock is held while systemd waits
|
|
62
|
+
# for its private readiness callback. Slot exclusion still prevents
|
|
63
|
+
# another generation, seal or maintenance from racing this start.
|
|
64
|
+
unless result.fetch(:replayed)
|
|
65
|
+
scope_observer_for(params.fetch("mapping_id")).start_admitted_service!
|
|
66
|
+
end
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scope_admission_owner!(params, map, events, peer, role)
|
|
71
|
+
state = states(events)[params.fetch("attempt_id")]
|
|
72
|
+
unless state && state["mapping_id"] == params.fetch("mapping_id") &&
|
|
73
|
+
state["launch_ticket"] == params.fetch("launch_ticket") &&
|
|
74
|
+
(role == :supervisor || @kernel.same?(state.fetch("launcher_identity"), peer))
|
|
75
|
+
raise AttemptErrors::UnauthorizedIdentity, "native admission launch origin differs"
|
|
76
|
+
end
|
|
77
|
+
lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
|
|
78
|
+
assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
|
|
79
|
+
[state, lineage]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class LaunchLifecycle
|
|
7
|
+
# Source-owned close core. Its first accepted mutation seals admission
|
|
8
|
+
# and caches the retained running reply before any manager stop. A
|
|
9
|
+
# subsequent fresh mutation may record positive observation; replay of
|
|
10
|
+
# the first mutation always keeps its original reply and performs no I/O.
|
|
11
|
+
def close_execution_scope!(params:, peer:, role:)
|
|
12
|
+
strict!(params, %w[mapping_id assignment_id attempt_id mutation_id expected_generation])
|
|
13
|
+
%w[assignment_id attempt_id mutation_id].each { |key| token!(params.fetch(key)) }
|
|
14
|
+
generation!(params.fetch("expected_generation"))
|
|
15
|
+
@kernel.live!(peer)
|
|
16
|
+
map = @deployment.mapping(params.fetch("mapping_id"))
|
|
17
|
+
journal = journal_for(map)
|
|
18
|
+
input = params.reject { |key, _| key == "mutation_id" }
|
|
19
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(input)))
|
|
20
|
+
with_exclusion(params, map, journal) do
|
|
21
|
+
stop_after_commit = false
|
|
22
|
+
result = @mutex.synchronize do
|
|
23
|
+
current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
24
|
+
scope_close_owner!(params, map, current, peer, role)
|
|
25
|
+
journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
26
|
+
mutation_id: params.fetch("mutation_id"), operation: "close_execution_scope", parameters_digest: digest,
|
|
27
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, _commit, _generation|
|
|
28
|
+
lineage = scope_close_owner!(params, map, events, peer, role)
|
|
29
|
+
binding = lineage.binding
|
|
30
|
+
raise AttemptErrors::EvidenceUnavailable, "original parent binding is missing" unless binding
|
|
31
|
+
projection = {"attempt_id" => params.fetch("attempt_id"), "scope_generation" => binding.fetch("scope_generation"),
|
|
32
|
+
"state" => "running", "proof_id" => nil, "required_action" => "close_scope"}
|
|
33
|
+
if !lineage.sealed?
|
|
34
|
+
stop_after_commit = true
|
|
35
|
+
payload = {"scope_generation" => binding.fetch("scope_generation"), "scope_binding_event_id" => lineage.binding_event.fetch("digest")}
|
|
36
|
+
{events: [{type: "scope_sealed", payload: payload}], blobs: {}, data: projection}
|
|
37
|
+
elsif lineage.proof_event
|
|
38
|
+
scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
|
|
39
|
+
{events: [], blobs: {}, data: projection.merge("state" => "closed_no_writers", "proof_id" => lineage.proof_id, "required_action" => nil)}
|
|
40
|
+
elsif scope_observer_for(params.fetch("mapping_id")).sealed_service_stop_required?(lineage)
|
|
41
|
+
# A fresh canonical close resumes a stop lost after the seal.
|
|
42
|
+
# Replaying either mutation performs no manager I/O.
|
|
43
|
+
stop_after_commit = true
|
|
44
|
+
{events: [], blobs: {}, data: projection}
|
|
45
|
+
else
|
|
46
|
+
# This call must return a fresh validated observation, never
|
|
47
|
+
# a cached population, caller JSON or a manifest boolean.
|
|
48
|
+
payload = scope_observer_for(params.fetch("mapping_id")).closed_observation_for_proof!(lineage, events: events)
|
|
49
|
+
expected = binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
|
|
50
|
+
"scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "populated" => 0)
|
|
51
|
+
unless payload == expected
|
|
52
|
+
raise AttemptErrors::EvidenceUnavailable, "fresh observation does not name the retained sealed parent"
|
|
53
|
+
end
|
|
54
|
+
at = Time.now.utc
|
|
55
|
+
event = Models::EvidenceEvent.build(type: "scope_closed_no_writers", attempt_id: params.fetch("attempt_id"),
|
|
56
|
+
payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: at)
|
|
57
|
+
{events: [{type: "scope_closed_no_writers", payload: payload, recorded_at: at}], blobs: {},
|
|
58
|
+
data: projection.merge("state" => "closed_no_writers", "proof_id" => event.fetch("digest"), "required_action" => nil)}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
if !result.fetch(:replayed) && stop_after_commit
|
|
63
|
+
scope_observer_for(params.fetch("mapping_id")).stop_sealed_service!
|
|
64
|
+
end
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def observe_execution_scope!(params:, peer:, role:)
|
|
70
|
+
strict!(params, %w[mapping_id assignment_id attempt_id])
|
|
71
|
+
%w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
|
|
72
|
+
@kernel.live!(peer)
|
|
73
|
+
map = @deployment.mapping(params.fetch("mapping_id"))
|
|
74
|
+
journal = journal_for(map)
|
|
75
|
+
with_exclusion(params, map, journal) do
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
commit = journal.ref_value
|
|
78
|
+
events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
79
|
+
lineage = scope_close_owner!(params, map, events, peer, role)
|
|
80
|
+
projection = {"attempt_id" => params.fetch("attempt_id"), "scope_generation" => lineage.binding&.fetch("scope_generation"),
|
|
81
|
+
"state" => "unverifiable", "proof_id" => nil, "required_action" => "inspect_exact_scope",
|
|
82
|
+
"generation" => journal.authority_generation(events), "journal_commit" => commit}
|
|
83
|
+
return projection unless lineage.binding
|
|
84
|
+
begin
|
|
85
|
+
observation = scope_observer_for(params.fetch("mapping_id")).observe(lineage)
|
|
86
|
+
if lineage.proof_event
|
|
87
|
+
scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
|
|
88
|
+
projection.merge("state" => "closed_no_writers", "proof_id" => lineage.proof_id, "required_action" => nil)
|
|
89
|
+
elsif lineage.sealed? && observation.fetch("populated").zero?
|
|
90
|
+
projection.merge("required_action" => "close_scope")
|
|
91
|
+
else
|
|
92
|
+
projection.merge("state" => "running", "required_action" => "close_scope")
|
|
93
|
+
end
|
|
94
|
+
rescue Ace::Runtime::RuntimeUnavailableError, AttemptErrors::EvidenceUnavailable
|
|
95
|
+
projection
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def scope_close_owner!(params, map, events, peer, role)
|
|
104
|
+
state = states(events)[params.fetch("attempt_id")]
|
|
105
|
+
unless state && state["mapping_id"] == params.fetch("mapping_id") &&
|
|
106
|
+
(role == :supervisor || role == :launcher && @kernel.same?(state.fetch("launcher_identity"), peer))
|
|
107
|
+
raise AttemptErrors::UnauthorizedIdentity, "scope closure requires the original launch owner"
|
|
108
|
+
end
|
|
109
|
+
Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
|
|
110
|
+
assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class LaunchLifecycle
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Called inside the existing slot/assignment exclusion, after the fresh
|
|
10
|
+
# reservation CAS and outside its authority mutex/journal ref lock.
|
|
11
|
+
# Mutation replay and restart never call this activation path.
|
|
12
|
+
def provision_reserved_parent_held!(reservation, map, journal)
|
|
13
|
+
params = reservation.slice("mapping_id", "assignment_id", "attempt_id")
|
|
14
|
+
context = params.merge("project_id" => map.fetch("project_id"),
|
|
15
|
+
"reservation_generation" => reservation.fetch("reservation_generation"),
|
|
16
|
+
"scope_generation" => reservation.fetch("generation") + 1)
|
|
17
|
+
observer = scope_observer_for(params.fetch("mapping_id"))
|
|
18
|
+
binding = observer.activate_parent!(context)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
21
|
+
mutation_id: "scope-parent-#{Digest::SHA256.hexdigest(params.fetch('attempt_id'))[0, 48]}",
|
|
22
|
+
operation: "scope_parent_binding", parameters_digest: Digest::SHA256.hexdigest(JSON.generate(canonical(context))),
|
|
23
|
+
expected_generation: reservation.fetch("generation"), with_replay: true) do |events, _commit, _generation|
|
|
24
|
+
state = states(events)[params.fetch("attempt_id")]
|
|
25
|
+
unless state && state["phase"] == "reserved" && state["launch_ticket"] == reservation.fetch("launch_ticket") &&
|
|
26
|
+
events.none? { |event| %w[scope_bound scope_native_bound scope_child_bound scope_sealed].include?(event["type"]) ||
|
|
27
|
+
event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_service_admission" }
|
|
28
|
+
raise AttemptErrors::Conflict, "parent activation cannot replace or reopen an existing generation"
|
|
29
|
+
end
|
|
30
|
+
at = Time.now.utc
|
|
31
|
+
event = Models::EvidenceEvent.build(type: "scope_bound", attempt_id: params.fetch("attempt_id"),
|
|
32
|
+
payload: binding, previous_digest: events.last&.fetch("digest"), recorded_at: at)
|
|
33
|
+
lineage = Molecules::ExecutionScopeLineage.new(events: events + [event], project_id: map.fetch("project_id"),
|
|
34
|
+
assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
|
|
35
|
+
observer.observe(lineage)
|
|
36
|
+
{events: [{type: "scope_bound", payload: binding, recorded_at: at}], blobs: {}, data: state.merge(
|
|
37
|
+
"scope_generation" => binding.fetch("scope_generation"), "scope_binding_event_id" => event.fetch("digest"))}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
class LaunchLifecycle
|
|
7
|
+
# Source-only maintenance entry. Transport stop/drain/inhibition belongs
|
|
8
|
+
# to the trusted installer; this method supplies the exact same locks
|
|
9
|
+
# and canonical owner query, without a live endpoint or another ledger.
|
|
10
|
+
def with_execution_slots(mapping_ids:, &block)
|
|
11
|
+
unless mapping_ids.is_a?(Array) && mapping_ids.size.between?(1, 64) && mapping_ids.uniq == mapping_ids
|
|
12
|
+
raise ArgumentError, "fixed execution slot selections must be unique and bounded"
|
|
13
|
+
end
|
|
14
|
+
maps = mapping_ids.map { |id| token!(id); [id, @deployment.mapping(id)] }
|
|
15
|
+
.sort_by { |_id, map| [map.fetch("authority_id"), map.fetch("execution_scope").fetch("slot_id")] }
|
|
16
|
+
enter = lambda do |offset|
|
|
17
|
+
if offset < maps.size
|
|
18
|
+
with_slot(maps.fetch(offset).last) { enter.call(offset + 1) }
|
|
19
|
+
else
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
commits = {}
|
|
22
|
+
contexts = maps.map do |id, map|
|
|
23
|
+
journal = journal_for(map)
|
|
24
|
+
commit = commits.fetch(map.fetch("project_id")) { commits[map.fetch("project_id")] = journal.ref_value }
|
|
25
|
+
{mapping_id: id.freeze, journal: journal, commit: commit&.freeze}.freeze
|
|
26
|
+
end.freeze
|
|
27
|
+
block.call(contexts)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
enter.call(0)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def slot_reusable!(mapping_id:, journal:, commit:)
|
|
35
|
+
map = @deployment.mapping(mapping_id)
|
|
36
|
+
require_slot_snapshot!(map, journal, commit)
|
|
37
|
+
ensure_slot_available!(map, journal, commit: commit)
|
|
38
|
+
require_slot_snapshot!(map, journal, commit)
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def retire_released_parent!(mapping_id:, journal:, commit:)
|
|
43
|
+
slot_reusable!(mapping_id: mapping_id, journal: journal, commit: commit)
|
|
44
|
+
map = @deployment.mapping(mapping_id)
|
|
45
|
+
slot = map.fetch("execution_scope").fetch("slot_id")
|
|
46
|
+
lineages = journal.assignment_ids(commit: commit).flat_map do |assignment_id|
|
|
47
|
+
journal.read_events(assignment_id, commit: commit).group_by { |event| event.fetch("attempt_id") }.filter_map do |attempt_id, events|
|
|
48
|
+
reservation = events.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
|
|
49
|
+
next unless reservation
|
|
50
|
+
prior_id = reservation.dig("payload", "data", "mapping_id")
|
|
51
|
+
next unless @deployment.mapping(prior_id).fetch("execution_scope").fetch("slot_id") == slot
|
|
52
|
+
Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
|
|
53
|
+
assignment_id: assignment_id, attempt_id: attempt_id, mapping_id: prior_id)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
result = scope_observer_for(mapping_id).retire_released_parent!(lineages)
|
|
57
|
+
require_slot_snapshot!(map, journal, commit)
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Current connected release producer is the guarded pre-release abort.
|
|
62
|
+
# Finish/stop owners will call the same settlement/proof core in their
|
|
63
|
+
# terminal CAS; those consumer handlers are not this task's ownership.
|
|
64
|
+
def release_scope_reservation!(params:, peer:, role:)
|
|
65
|
+
strict!(params, %w[mapping_id assignment_id attempt_id mutation_id expected_generation])
|
|
66
|
+
%w[assignment_id attempt_id mutation_id].each { |key| token!(params.fetch(key)) }
|
|
67
|
+
generation!(params.fetch("expected_generation"))
|
|
68
|
+
@kernel.live!(peer)
|
|
69
|
+
map = @deployment.mapping(params.fetch("mapping_id"))
|
|
70
|
+
journal = journal_for(map)
|
|
71
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(params.reject { |key, _| key == "mutation_id" })))
|
|
72
|
+
with_exclusion(params, map, journal) do
|
|
73
|
+
release_scope_reservation_held!(params, map, journal, peer, role, digest)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def release_scope_reservation_held!(params, map, journal, peer, role, digest)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
82
|
+
scope_close_owner!(params, map, current, peer, role)
|
|
83
|
+
journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
84
|
+
mutation_id: params.fetch("mutation_id"), operation: "scope_reservation_release", parameters_digest: digest,
|
|
85
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, commit, _generation|
|
|
86
|
+
lineage = scope_close_owner!(params, map, events, peer, role)
|
|
87
|
+
terminal = guarded_scope_abort_receipt!(events, lineage, journal, commit)
|
|
88
|
+
scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
|
|
89
|
+
scope_settlement_complete!(journal: journal, events: events, params: params, map: map, commit: commit)
|
|
90
|
+
if events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
|
|
91
|
+
raise AttemptErrors::Conflict, "reservation is already released"
|
|
92
|
+
end
|
|
93
|
+
{events: [], blobs: {}, data: {"assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
|
|
94
|
+
"mapping_id" => params.fetch("mapping_id"), "scope_generation" => lineage.binding.fetch("scope_generation"),
|
|
95
|
+
"scope_binding_event_id" => lineage.binding_event.fetch("digest"), "proof_id" => lineage.proof_id,
|
|
96
|
+
"terminal_event_id" => terminal.fetch("digest"), "reservation" => "released"}}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A crash after guarded abort must not strand a known-clean reservation.
|
|
102
|
+
# Resume against the current canonical generation under the same slot
|
|
103
|
+
# exclusion; preserve the original immutable public abort reply.
|
|
104
|
+
def resume_scope_release_held!(response, map, journal, peer, role)
|
|
105
|
+
data = response.fetch(:data)
|
|
106
|
+
params = data.slice("mapping_id", "assignment_id", "attempt_id")
|
|
107
|
+
@mutex.synchronize do
|
|
108
|
+
commit = journal.ref_value
|
|
109
|
+
events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
110
|
+
lineage = scope_close_owner!(params, map, events, peer, role)
|
|
111
|
+
return if scope_reservation_released?(events, lineage, journal, commit)
|
|
112
|
+
params["expected_generation"] = journal.authority_generation(events)
|
|
113
|
+
end
|
|
114
|
+
params["mutation_id"] = "scope-release-#{Digest::SHA256.hexdigest(params.fetch('attempt_id'))[0, 48]}"
|
|
115
|
+
digest = Digest::SHA256.hexdigest(JSON.generate(canonical(params.reject { |key, _| key == "mutation_id" })))
|
|
116
|
+
release_scope_reservation_held!(params, map, journal, peer, role, digest)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def require_slot_snapshot!(map, journal, commit)
|
|
120
|
+
key = [object_id, map.fetch("authority_id"), map.fetch("execution_scope").fetch("slot_id")]
|
|
121
|
+
unless Thread.current[:ace_assign_scope_exclusions]&.fetch(key, false) && journal.equal?(journal_for(map)) && journal.ref_value == commit
|
|
122
|
+
raise AttemptErrors::EvidenceUnavailable, "slot query requires its held exclusion and exact current canonical ref"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def guarded_scope_abort_receipt!(events, lineage, journal, commit)
|
|
127
|
+
aborts = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "abort_launch" }
|
|
128
|
+
terminal = aborts.last
|
|
129
|
+
data = terminal&.dig("payload", "data")
|
|
130
|
+
observation = data&.fetch("abort_observation", nil)
|
|
131
|
+
unless data && data["phase"] == "failed" && observation.is_a?(Hash) && observation["release"] == "not_issued" &&
|
|
132
|
+
observation.keys.sort == (SCOPE_ABORT_FIELDS + ["release"]).sort &&
|
|
133
|
+
observation["kind"] == "protected_scope_before_release" && journal.canonical_attempt_state(events) == "failed" &&
|
|
134
|
+
events.none? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" }
|
|
135
|
+
raise AttemptErrors::EvidenceUnavailable, "verified guarded terminal release is unavailable"
|
|
136
|
+
end
|
|
137
|
+
selectors = observation.reject { |key, _| %w[kind release].include?(key) }
|
|
138
|
+
lineage.require_positive!(**selectors.transform_keys(&:to_sym))
|
|
139
|
+
bytes = journal.blob(data.fetch("failure_ref"), commit: commit)
|
|
140
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 16_384) && Digest::SHA256.hexdigest(bytes) == data.fetch("failure_digest")
|
|
141
|
+
raise AttemptErrors::EvidenceUnavailable, "guarded terminal failure bytes changed"
|
|
142
|
+
end
|
|
143
|
+
failure = JSON.parse(bytes.dup.force_encoding(Encoding::UTF_8), create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
|
|
144
|
+
unless failure == observation.reject { |key, _| key == "release" }
|
|
145
|
+
raise AttemptErrors::EvidenceUnavailable, "guarded terminal failure selectors changed"
|
|
146
|
+
end
|
|
147
|
+
preceding = events.take_while { |event| event["digest"] != terminal.fetch("digest") }
|
|
148
|
+
transition = preceding.last
|
|
149
|
+
unless transition && %w[transition reconciliation].include?(transition["type"]) &&
|
|
150
|
+
transition.dig("payload", "reason") == "protected_scope_closed_before_release" &&
|
|
151
|
+
(transition.dig("payload", "to") || transition.dig("payload", "resolution")) == "failed" &&
|
|
152
|
+
transition.dig("payload", "abort_observation") == observation
|
|
153
|
+
raise AttemptErrors::EvidenceUnavailable, "guarded terminal transition differs"
|
|
154
|
+
end
|
|
155
|
+
terminal
|
|
156
|
+
rescue KeyError, JSON::ParserError, EncodingError
|
|
157
|
+
raise AttemptErrors::EvidenceUnavailable, "guarded terminal lineage is incomplete"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def scope_reservation_released?(events, lineage, journal, commit)
|
|
161
|
+
releases = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
|
|
162
|
+
return false if releases.empty?
|
|
163
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical reservation release is repeated" unless releases.size == 1
|
|
164
|
+
terminal = guarded_scope_abort_receipt!(events, lineage, journal, commit)
|
|
165
|
+
data = releases.first.dig("payload", "data")
|
|
166
|
+
expected = {"assignment_id" => lineage.binding.fetch("assignment_id"), "attempt_id" => lineage.binding.fetch("attempt_id"),
|
|
167
|
+
"mapping_id" => lineage.binding.fetch("mapping_id"), "scope_generation" => lineage.binding.fetch("scope_generation"),
|
|
168
|
+
"scope_binding_event_id" => lineage.binding_event.fetch("digest"), "proof_id" => lineage.proof_id,
|
|
169
|
+
"terminal_event_id" => terminal.fetch("digest"), "reservation" => "released"}
|
|
170
|
+
unless data.is_a?(Hash) && data.reject { |key, _| key == "generation" } == expected &&
|
|
171
|
+
events.index(releases.first) > events.index(terminal) &&
|
|
172
|
+
data["generation"] == events.take(events.index(releases.first) + 1).count { |event| event["type"] == "authority_mutation" }
|
|
173
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical reservation release selectors differ"
|
|
174
|
+
end
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "fiddle"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Assign
|
|
6
|
+
module Authority
|
|
7
|
+
# Linux POSIX access ACL evaluation for a fixed unprivileged principal.
|
|
8
|
+
# No credential switching; unsupported or unreadable policy refuses admission.
|
|
9
|
+
class PosixAcl
|
|
10
|
+
def entries(path)
|
|
11
|
+
raise Ace::Runtime::RuntimeUnavailableError, "receiver ACL inspection requires Linux" unless RUBY_PLATFORM.include?("linux")
|
|
12
|
+
function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["lgetxattr"],
|
|
13
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
|
|
14
|
+
buffer = Fiddle::Pointer.malloc(8192, Fiddle::RUBY_FREE)
|
|
15
|
+
count = function.call(path, "system.posix_acl_access", buffer, 8192)
|
|
16
|
+
return nil if count == -1 && Fiddle.last_error == Errno::ENODATA::Errno
|
|
17
|
+
raise Ace::Runtime::RuntimeUnavailableError, "receiver access ACL is unreadable" if count < 4 || (count - 4) % 8 != 0
|
|
18
|
+
bytes = buffer[0, count]
|
|
19
|
+
raise Ace::Runtime::RuntimeUnavailableError, "unknown receiver ACL version" unless bytes.unpack1("L<") == 2
|
|
20
|
+
rows = bytes.byteslice(4..).unpack("S<S<L<" * ((count - 4) / 8)).each_slice(3).to_a
|
|
21
|
+
validate!(rows)
|
|
22
|
+
rows
|
|
23
|
+
rescue Fiddle::DLError
|
|
24
|
+
raise Ace::Runtime::RuntimeUnavailableError, "receiver ACL inspection is unavailable"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def searchable?(path, stat:, uid:, groups:)
|
|
28
|
+
rows = entries(path)
|
|
29
|
+
unless rows
|
|
30
|
+
shift = uid == stat.uid ? 6 : (groups.include?(stat.gid) ? 3 : 0)
|
|
31
|
+
return ((stat.mode >> shift) & 1) == 1
|
|
32
|
+
end
|
|
33
|
+
return (rows.find { |tag, _perm, _id| tag == 1 }[1] & 1) == 1 if uid == stat.uid
|
|
34
|
+
mask = rows.find { |tag, _perm, _id| tag == 16 }&.at(1) || 7
|
|
35
|
+
named = rows.find { |tag, _perm, id| tag == 2 && id == uid }
|
|
36
|
+
return (named[1] & mask & 1) == 1 if named
|
|
37
|
+
matched = rows.select { |tag, _perm, id| (tag == 4 && groups.include?(stat.gid)) || (tag == 8 && groups.include?(id)) }
|
|
38
|
+
return matched.any? { |_tag, perm, _id| (perm & mask & 1) == 1 } unless matched.empty?
|
|
39
|
+
(rows.find { |tag, _perm, _id| tag == 32 }[1] & 1) == 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def validate!(rows)
|
|
45
|
+
valid = rows.all? { |tag, perm, id| [1, 2, 4, 8, 16, 32].include?(tag) && (0..7).cover?(perm) &&
|
|
46
|
+
([2, 8].include?(tag) ? id != 0xffffffff : id == 0xffffffff) }
|
|
47
|
+
valid &&= [1, 4, 32].all? { |tag| rows.count { |row| row[0] == tag } == 1 }
|
|
48
|
+
valid &&= rows.count { |row| row[0] == 16 } <= 1
|
|
49
|
+
valid &&= [2, 8].all? { |tag| ids = rows.select { |row| row[0] == tag }.map(&:last); ids.uniq == ids }
|
|
50
|
+
valid &&= !rows.any? { |row| [2, 8].include?(row[0]) } || rows.count { |row| row[0] == 16 } == 1
|
|
51
|
+
raise Ace::Runtime::RuntimeUnavailableError, "invalid receiver access ACL" unless valid
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
module PrivateDirectory
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Source configuration chooses this path. Each ancestor must prevent
|
|
10
|
+
# another principal from replacing the private leaf or its contents.
|
|
11
|
+
def verify!(root)
|
|
12
|
+
path = File.expand_path(root)
|
|
13
|
+
cursor = path
|
|
14
|
+
loop do
|
|
15
|
+
stat = File.lstat(cursor)
|
|
16
|
+
unless stat.directory? && !stat.symlink? && [0, Process.uid].include?(stat.uid) && (stat.mode & 0022).zero?
|
|
17
|
+
raise AttemptErrors::ReceiptRejected, "Private directory ancestry is not protected"
|
|
18
|
+
end
|
|
19
|
+
break if cursor == "/"
|
|
20
|
+
cursor = File.dirname(cursor)
|
|
21
|
+
end
|
|
22
|
+
stat = File.stat(path)
|
|
23
|
+
unless stat.uid == Process.uid && (stat.mode & 0077).zero?
|
|
24
|
+
raise AttemptErrors::ReceiptRejected, "Private directory must be owned by this peer and private"
|
|
25
|
+
end
|
|
26
|
+
path
|
|
27
|
+
rescue SystemCallError, TypeError
|
|
28
|
+
raise AttemptErrors::ReceiptRejected, "Private directory is unavailable"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|