ace-lab 0.8.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -92
- data/README.md +66 -100
- data/docs/usage.md +529 -404
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -13
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -102
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -45
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ace/assign/authority/service_evidence"
|
|
4
|
-
require_relative "protected_cleanup_owner_client"
|
|
5
|
-
require "ace/assign/authority/endcap"
|
|
6
|
-
require "ace/assign/authority/launch_lifecycle"
|
|
7
|
-
|
|
8
|
-
module Ace
|
|
9
|
-
module Lab
|
|
10
|
-
module Molecules
|
|
11
|
-
# Only the fixed listener supplies the installed journal snapshot and its
|
|
12
|
-
# independently observed self binding. A frame selects neither owner.
|
|
13
|
-
class ProtectedCleanupOwnerAdmission
|
|
14
|
-
FIELDS = %w[schema kind request_id input_digest claim_binding request_event_digest
|
|
15
|
-
dispatch_event_digest input operation_owner_binding_digest].freeze
|
|
16
|
-
INSPECT_FIELDS = (FIELDS + %w[challenge_ref]).freeze
|
|
17
|
-
|
|
18
|
-
def initialize(deployment:, authority_id:, mapping_id:, service_id:, kernel:, preview_policy: nil)
|
|
19
|
-
@deployment, @authority_id, @kernel = deployment, authority_id, kernel
|
|
20
|
-
@mapping_id, @service_id = mapping_id, service_id
|
|
21
|
-
@preview_policy = preview_policy
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def identity_reader!(peer)
|
|
25
|
-
authority = @deployment.authority(@authority_id)
|
|
26
|
-
if peer.slice("uid", "gid", "groups") == authority.slice("uid", "gid", "groups")
|
|
27
|
-
@kernel.live!(peer)
|
|
28
|
-
return true
|
|
29
|
-
end
|
|
30
|
-
receiver!(peer)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def connection_group!(gid)
|
|
34
|
-
authority = @deployment.authority(@authority_id)
|
|
35
|
-
map = @deployment.mapping(@mapping_id)
|
|
36
|
-
project = @deployment.project(map.fetch("project_id"))
|
|
37
|
-
receiver = project.fetch("service_receivers").fetch(@service_id)
|
|
38
|
-
credentials = project.fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
|
|
39
|
-
unless gid.is_a?(Integer) && gid.positive? && [authority, credentials].all? { |principal| principal.fetch("gid") == gid || principal.fetch("groups").include?(gid) }
|
|
40
|
-
raise SecurityError, "cleanup connection group differs from installed principals"
|
|
41
|
-
end
|
|
42
|
-
true
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def receiver!(peer)
|
|
46
|
-
map = @deployment.mapping(@mapping_id)
|
|
47
|
-
project = @deployment.project(map.fetch("project_id"))
|
|
48
|
-
receiver = project.fetch("service_receivers").fetch(@service_id)
|
|
49
|
-
credentials = project.fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
|
|
50
|
-
unless map.fetch("authority_id") == @authority_id && peer.fetch("uid").is_a?(Integer) && peer.fetch("uid").positive? &&
|
|
51
|
-
peer.fetch("uid") == receiver.fetch("executor_uid") && peer.fetch("gid") == credentials.fetch("gid") &&
|
|
52
|
-
peer.fetch("groups") == credentials.fetch("groups")
|
|
53
|
-
raise SecurityError, "cleanup installed receiver differs"
|
|
54
|
-
end
|
|
55
|
-
@kernel.live!(peer)
|
|
56
|
-
true
|
|
57
|
-
rescue KeyError, TypeError
|
|
58
|
-
raise SecurityError, "cleanup installed receiver unavailable"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def preview!(frame:, peer:, operation_owner_binding:, journal:)
|
|
62
|
-
receiver!(peer)
|
|
63
|
-
Atoms::ProtectedWorkspacePruneInput.object!(frame, %w[schema kind intent maintenance_context])
|
|
64
|
-
raise SecurityError, "cleanup preview kind differs" unless frame.values_at("schema", "kind") == [ProtectedCleanupOwnerClient::SCHEMA, "preview"]
|
|
65
|
-
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(frame.fetch("intent"))
|
|
66
|
-
supplied = Atoms::ProtectedWorkspacePrunePreview.context!(frame.fetch("maintenance_context"))
|
|
67
|
-
maintenance = supplied.fetch("maintenance")
|
|
68
|
-
map = @deployment.mapping(@mapping_id)
|
|
69
|
-
unless intent.fetch("maintenance") == maintenance && maintenance.fetch("mapping_id") == @mapping_id &&
|
|
70
|
-
maintenance.fetch("project_id") == map.fetch("project_id") && map.fetch("authority_id") == @authority_id
|
|
71
|
-
raise SecurityError, "cleanup preview installed scope differs"
|
|
72
|
-
end
|
|
73
|
-
raise SecurityError, "cleanup preview policy composition unavailable" unless @preview_policy
|
|
74
|
-
launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, kernel: @kernel)
|
|
75
|
-
owner = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel, service_policy: @preview_policy)
|
|
76
|
-
params = maintenance.except("project_id").merge("service_id" => @service_id,
|
|
77
|
-
"worker_process_binding" => supplied.fetch("caller_process_binding"))
|
|
78
|
-
selected = owner.workspace_prune_preview_context_at!(journal: journal, params: params, map: map, peer: peer, role: :executor)
|
|
79
|
-
raise SecurityError, "cleanup preview canonical selection changed" unless selected == supplied
|
|
80
|
-
Atoms::ProtectedWorkspacePruneInput.freeze_value({"intent" => intent, "maintenance_context" => selected,
|
|
81
|
-
"operation_owner_binding" => operation_owner_binding})
|
|
82
|
-
rescue KeyError, TypeError, ArgumentError
|
|
83
|
-
raise SecurityError, "cleanup preview canonical selection unavailable"
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def admit!(frame:, peer:, operation_owner_binding:, journal:)
|
|
87
|
-
admit_original!(frame: frame, peer: peer, operation_owner_binding: operation_owner_binding, journal: journal, purpose: :execute)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def inspect!(frame:, peer:, operation_owner_binding:, journal:)
|
|
91
|
-
context = admit_original!(frame: frame, peer: peer, operation_owner_binding: operation_owner_binding, journal: journal, purpose: :inspect)
|
|
92
|
-
reference = frame.fetch("challenge_ref")
|
|
93
|
-
Atoms::ProtectedWorkspacePruneInput.object!(reference, %w[challenge_event_digest])
|
|
94
|
-
Atoms::ProtectedWorkspacePruneInput.digest!(reference.fetch("challenge_event_digest"))
|
|
95
|
-
challenge = Ace::Assign::Authority::ServiceEvidence.new(journal: journal).challenge!(context.fetch("record"), pending: {commit: context.fetch("commit")})
|
|
96
|
-
unless challenge.fetch("digest") == reference.fetch("challenge_event_digest")
|
|
97
|
-
raise SecurityError, "cleanup inspection challenge differs"
|
|
98
|
-
end
|
|
99
|
-
Atoms::ProtectedWorkspacePruneInput.freeze_value(context.merge("challenge" => challenge))
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
private
|
|
103
|
-
|
|
104
|
-
def admit_original!(frame:, peer:, operation_owner_binding:, journal:, purpose:)
|
|
105
|
-
receiver!(peer)
|
|
106
|
-
Atoms::ProtectedWorkspacePruneInput.object!(frame, purpose == :execute ? FIELDS : INSPECT_FIELDS)
|
|
107
|
-
unless frame.values_at("schema", "kind") == [ProtectedCleanupOwnerClient::SCHEMA, purpose.to_s]
|
|
108
|
-
raise SecurityError, "cleanup invocation kind differs"
|
|
109
|
-
end
|
|
110
|
-
Atoms::ProtectedWorkspacePruneInput.token!(frame.fetch("request_id"))
|
|
111
|
-
(FIELDS - %w[schema kind request_id input]).each do |key|
|
|
112
|
-
Atoms::ProtectedWorkspacePruneInput.digest!(frame.fetch(key))
|
|
113
|
-
end
|
|
114
|
-
input = Atoms::ProtectedWorkspacePruneInput.parse(JSON.generate(frame.fetch("input")))
|
|
115
|
-
unless Atoms::ServiceInput.digest(input) == frame.fetch("input_digest") &&
|
|
116
|
-
(purpose == :inspect || digest(operation_owner_binding) == frame.fetch("operation_owner_binding_digest"))
|
|
117
|
-
raise SecurityError, "cleanup original input or owner differs"
|
|
118
|
-
end
|
|
119
|
-
maintenance = input.fetch("maintenance")
|
|
120
|
-
map = @deployment.mapping(maintenance.fetch("mapping_id"))
|
|
121
|
-
unless maintenance.fetch("mapping_id") == @mapping_id && map.fetch("authority_id") == @authority_id && map.fetch("project_id") == maintenance.fetch("project_id")
|
|
122
|
-
raise SecurityError, "cleanup installed scope differs"
|
|
123
|
-
end
|
|
124
|
-
unless journal.evidence_mode == :protected
|
|
125
|
-
raise SecurityError, "cleanup requires the installed protected service evidence owner"
|
|
126
|
-
end
|
|
127
|
-
commit = journal.ref_value
|
|
128
|
-
journal.verify_canonical_prefix!(commit: commit)
|
|
129
|
-
record = journal.service_request(frame.fetch("request_id"), commit: commit)
|
|
130
|
-
unless record && record.fetch("service_id") == @service_id && record.slice("project_id", "mapping_id", "assignment_id", "attempt_id") == maintenance &&
|
|
131
|
-
record.values_at("operation", "dispatch_phase") == ["prune-preserved-workspace", "dispatch_started"] &&
|
|
132
|
-
(purpose == :execute ? record["state"] == "uncertain" : %w[uncertain failed].include?(record["state"])) &&
|
|
133
|
-
record.slice("request_id", "input_digest", "claim_binding") == frame.slice("request_id", "input_digest", "claim_binding") &&
|
|
134
|
-
record.fetch("target") == Atoms::ServiceInput.target(input) &&
|
|
135
|
-
digest(record.fetch("operation_owner_binding")) == frame.fetch("operation_owner_binding_digest") &&
|
|
136
|
-
(purpose == :inspect || digest(record.fetch("executor_process_binding")) == digest(peer))
|
|
137
|
-
raise SecurityError, "cleanup canonical original dispatch differs"
|
|
138
|
-
end
|
|
139
|
-
Ace::Assign::Authority::ServiceEvidence.new(journal: journal).context(record, pending: {commit: commit})
|
|
140
|
-
receiver = @deployment.project(map.fetch("project_id")).fetch("service_receivers").fetch(record.fetch("service_id"))
|
|
141
|
-
credentials = @deployment.project(map.fetch("project_id")).fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
|
|
142
|
-
unless peer.fetch("uid") == record.fetch("executor_uid") && peer.fetch("uid") == receiver.fetch("executor_uid") &&
|
|
143
|
-
peer.fetch("gid") == credentials.fetch("gid") && peer.fetch("groups") == credentials.fetch("groups")
|
|
144
|
-
raise SecurityError, "cleanup original executor differs"
|
|
145
|
-
end
|
|
146
|
-
@kernel.live!(peer)
|
|
147
|
-
proof = Ace::Assign::Authority::ServiceEvidence.new(journal: journal).cleanup_dispatch_context!(
|
|
148
|
-
record, commit: commit, selectors: frame.slice("request_event_digest", "dispatch_event_digest"))
|
|
149
|
-
if purpose == :execute && proof.fetch("dispatch_record") != record
|
|
150
|
-
raise SecurityError, "cleanup original executable record differs"
|
|
151
|
-
end
|
|
152
|
-
Atoms::ProtectedWorkspacePruneInput.freeze_value(JSON.parse(JSON.generate({"commit" => commit,
|
|
153
|
-
"record" => record, "input" => input, "operation_owner_binding" => operation_owner_binding,
|
|
154
|
-
"request_event_digest" => frame.fetch("request_event_digest"), "dispatch_event_digest" => frame.fetch("dispatch_event_digest")})))
|
|
155
|
-
rescue KeyError, TypeError, ArgumentError
|
|
156
|
-
raise SecurityError, "cleanup canonical admission is unavailable"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def digest(value) = Ace::Assign::Atoms::EvidenceDigest.digest(value)
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ace/runtime/molecules/protected_socket"
|
|
4
|
-
require "ace/assign/authority/transfer_codec"
|
|
5
|
-
require "ace/assign/atoms/evidence_digest"
|
|
6
|
-
require_relative "../atoms/protected_workspace_prune_input"
|
|
7
|
-
require_relative "../atoms/service_input"
|
|
8
|
-
require_relative "../atoms/protected_workspace_prune_preview"
|
|
9
|
-
|
|
10
|
-
module Ace
|
|
11
|
-
module Lab
|
|
12
|
-
module Molecules
|
|
13
|
-
# Fixed installed endpoint. A fresh execute connection must independently
|
|
14
|
-
# match the original canonical binding; identity EOF is never permission.
|
|
15
|
-
class ProtectedCleanupOwnerClient
|
|
16
|
-
PATH = "/run/lab/protected-workspace-cleanup.sock"
|
|
17
|
-
SCHEMA = "lab.protected-workspace-cleanup-control/v1"
|
|
18
|
-
LIMIT = 16_384
|
|
19
|
-
|
|
20
|
-
def initialize(observer:, scratch_root:, wire: Ace::Runtime::Molecules::ProtectedSocket)
|
|
21
|
-
@observer, @wire = observer, wire
|
|
22
|
-
@codec = Ace::Assign::Authority::TransferCodec.new(root: scratch_root)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def identity!
|
|
26
|
-
deadline = @wire.deadline(5)
|
|
27
|
-
connect(deadline) do |socket, binding|
|
|
28
|
-
@wire.write(socket, {"schema" => SCHEMA, "kind" => "identity"}, deadline: deadline, limit: LIMIT)
|
|
29
|
-
socket.shutdown(Socket::SHUT_WR)
|
|
30
|
-
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
31
|
-
reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
|
|
32
|
-
eof!(guarded, deadline)
|
|
33
|
-
unless reply.is_a?(Hash) && reply.keys.sort == %w[kind operation_owner_binding schema] &&
|
|
34
|
-
reply["schema"] == SCHEMA && reply["kind"] == "identity" &&
|
|
35
|
-
digest(reply["operation_owner_binding"]) == digest(binding)
|
|
36
|
-
raise SecurityError, "cleanup identity reply differs from observed original owner"
|
|
37
|
-
end
|
|
38
|
-
binding
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def preview!(intent:, maintenance_context:, deadline: @wire.deadline(5))
|
|
43
|
-
intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
|
|
44
|
-
context = Atoms::ProtectedWorkspacePrunePreview.context!(JSON.parse(JSON.generate(maintenance_context)))
|
|
45
|
-
raise SecurityError, "preview maintenance differs" unless intent.fetch("maintenance") == context.fetch("maintenance")
|
|
46
|
-
connect(deadline) do |socket, _original|
|
|
47
|
-
@wire.write(socket, {"schema" => SCHEMA, "kind" => "preview", "intent" => intent,
|
|
48
|
-
"maintenance_context" => context}, deadline: deadline, limit: 65_536)
|
|
49
|
-
socket.shutdown(Socket::SHUT_WR)
|
|
50
|
-
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
51
|
-
result = @wire.read(guarded, deadline: deadline, limit: LIMIT)
|
|
52
|
-
eof!(guarded, deadline)
|
|
53
|
-
Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: context)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def execute!(request:, operation_owner_binding:)
|
|
58
|
-
request = JSON.parse(JSON.generate(request), create_additions: false, max_nesting: 16)
|
|
59
|
-
fields = %w[request_id input_digest claim_binding request_event_digest dispatch_event_digest input]
|
|
60
|
-
Atoms::ProtectedWorkspacePruneInput.object!(request, fields)
|
|
61
|
-
Atoms::ProtectedWorkspacePruneInput.token!(request.fetch("request_id"))
|
|
62
|
-
(fields - %w[request_id input]).each { |key| Atoms::ProtectedWorkspacePruneInput.digest!(request.fetch(key)) }
|
|
63
|
-
bytes = JSON.generate(request.fetch("input"))
|
|
64
|
-
input = Atoms::ProtectedWorkspacePruneInput.parse(bytes)
|
|
65
|
-
unless Atoms::ServiceInput.digest(input) == request.fetch("input_digest")
|
|
66
|
-
raise SecurityError, "cleanup execute input differs"
|
|
67
|
-
end
|
|
68
|
-
Atoms::ProtectedWorkspacePruneInput.freeze_value(request)
|
|
69
|
-
deadline = @wire.deadline(300)
|
|
70
|
-
admission_deadline = [deadline, @wire.deadline(5)].min
|
|
71
|
-
connect(admission_deadline) do |socket, observed|
|
|
72
|
-
unless digest(observed) == digest(operation_owner_binding)
|
|
73
|
-
raise SecurityError, "cleanup execute owner differs from original dispatch"
|
|
74
|
-
end
|
|
75
|
-
frame = request.merge("schema" => SCHEMA, "kind" => "execute",
|
|
76
|
-
"operation_owner_binding_digest" => digest(observed))
|
|
77
|
-
@wire.write(socket, frame, deadline: admission_deadline, limit: 65_536)
|
|
78
|
-
socket.shutdown(Socket::SHUT_WR)
|
|
79
|
-
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
80
|
-
reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
|
|
81
|
-
unless reply.is_a?(Hash) && reply.keys.sort == %w[input_digest kind receipt_ref request_id schema] &&
|
|
82
|
-
reply.values_at("schema", "kind", "request_id", "input_digest") ==
|
|
83
|
-
[SCHEMA, "result", request.fetch("request_id"), request.fetch("input_digest")]
|
|
84
|
-
raise SecurityError, "cleanup result identity differs"
|
|
85
|
-
end
|
|
86
|
-
ref = reply.fetch("receipt_ref")
|
|
87
|
-
Atoms::ProtectedWorkspacePruneInput.reference!(ref)
|
|
88
|
-
descriptor = {"version" => 1, "bytes" => ref.fetch("bytes"), "sha256" => ref.fetch("sha256"),
|
|
89
|
-
"parts" => [ref.slice("bytes", "sha256")]}
|
|
90
|
-
@codec.receive(guarded, descriptor: descriptor, purpose: :artifacts, deadline: deadline) do |received|
|
|
91
|
-
Atoms::ProtectedWorkspacePruneInput.freeze_value(ref)
|
|
92
|
-
{receipt_ref: ref, bytes: received.bytes.freeze}.freeze
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def inspect!(request:, operation_owner_binding:)
|
|
98
|
-
request = JSON.parse(JSON.generate(request), create_additions: false, max_nesting: 16)
|
|
99
|
-
fields = %w[request_id input_digest claim_binding request_event_digest dispatch_event_digest input challenge_ref]
|
|
100
|
-
Atoms::ProtectedWorkspacePruneInput.object!(request, fields)
|
|
101
|
-
Atoms::ProtectedWorkspacePruneInput.token!(request.fetch("request_id"))
|
|
102
|
-
(fields - %w[request_id input challenge_ref]).each { |key| Atoms::ProtectedWorkspacePruneInput.digest!(request.fetch(key)) }
|
|
103
|
-
Atoms::ProtectedWorkspacePruneInput.object!(request.fetch("challenge_ref"), %w[challenge_event_digest])
|
|
104
|
-
Atoms::ProtectedWorkspacePruneInput.digest!(request.dig("challenge_ref", "challenge_event_digest"))
|
|
105
|
-
input = Atoms::ProtectedWorkspacePruneInput.parse(JSON.generate(request.fetch("input")))
|
|
106
|
-
raise SecurityError, "cleanup inspection input differs" unless Atoms::ServiceInput.digest(input) == request.fetch("input_digest")
|
|
107
|
-
frame = request.merge("schema" => SCHEMA, "kind" => "inspect", "operation_owner_binding_digest" => digest(operation_owner_binding))
|
|
108
|
-
Atoms::ProtectedWorkspacePruneInput.freeze_value(frame)
|
|
109
|
-
deadline = @wire.deadline(300)
|
|
110
|
-
admission_deadline = [deadline, @wire.deadline(5)].min
|
|
111
|
-
connect(admission_deadline) do |socket, _observed|
|
|
112
|
-
@wire.write(socket, frame, deadline: admission_deadline, limit: 65_536)
|
|
113
|
-
socket.shutdown(Socket::SHUT_WR)
|
|
114
|
-
guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
|
|
115
|
-
reply = @wire.read(guarded, deadline: deadline, limit: LIMIT)
|
|
116
|
-
success = reply.is_a?(Hash) && reply["kind"] == "completed-result"
|
|
117
|
-
ref_key = success ? "receipt_ref" : "inspection_ref"
|
|
118
|
-
unless reply.is_a?(Hash) && reply.keys.sort == (["input_digest", ref_key, "kind", "request_id", "schema"].sort) &&
|
|
119
|
-
reply.values_at("schema", "kind", "request_id", "input_digest") ==
|
|
120
|
-
[SCHEMA, success ? "completed-result" : "inspection", request.fetch("request_id"), request.fetch("input_digest")]
|
|
121
|
-
raise SecurityError, "cleanup inspection result differs"
|
|
122
|
-
end
|
|
123
|
-
ref = reply.fetch(ref_key)
|
|
124
|
-
Atoms::ProtectedWorkspacePruneInput.reference!(ref)
|
|
125
|
-
descriptor = {"version" => 1, "bytes" => ref.fetch("bytes"), "sha256" => ref.fetch("sha256"), "parts" => [ref.slice("bytes", "sha256")]}
|
|
126
|
-
@codec.receive(guarded, descriptor: descriptor, purpose: :artifacts, deadline: deadline) do |received|
|
|
127
|
-
Atoms::ProtectedWorkspacePruneInput.freeze_value(ref)
|
|
128
|
-
{kind: success ? "succeeded" : "failed-no-effect", receipt_ref: ref, bytes: received.bytes.freeze}.freeze
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
private
|
|
134
|
-
|
|
135
|
-
def digest(value) = Ace::Assign::Atoms::EvidenceDigest.digest(value)
|
|
136
|
-
|
|
137
|
-
def connect(deadline)
|
|
138
|
-
@wire.root_path!(File.dirname(PATH), directory: true)
|
|
139
|
-
before = @wire.socket_identity(PATH, mode: 0o660)
|
|
140
|
-
raise SecurityError, "cleanup endpoint owner differs" unless before.last == 0
|
|
141
|
-
@wire.connect(PATH, deadline: deadline) do |socket|
|
|
142
|
-
observed = @observer.observe!(socket: socket, deadline: deadline)
|
|
143
|
-
raise SecurityError, "cleanup endpoint was replaced" unless @wire.socket_identity(PATH, mode: 0o660) == before
|
|
144
|
-
yield socket, observed
|
|
145
|
-
end
|
|
146
|
-
rescue IOError, SystemCallError
|
|
147
|
-
raise Ace::Runtime::RuntimeUnavailableError, "cleanup original transport unavailable"
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def eof!(socket, deadline)
|
|
151
|
-
loop do
|
|
152
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
153
|
-
unless remaining.positive? && IO.select([socket], nil, nil, remaining)
|
|
154
|
-
raise Ace::Runtime::RuntimeUnavailableError, "cleanup reply EOF deadline expired"
|
|
155
|
-
end
|
|
156
|
-
byte = socket.read_nonblock(1, exception: false)
|
|
157
|
-
next if byte == :wait_readable
|
|
158
|
-
raise SecurityError, "cleanup reply has trailing bytes" unless byte.nil?
|
|
159
|
-
return
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fiddle"
|
|
4
|
-
require "socket"
|
|
5
|
-
require "ace/runtime/molecules/process_identity"
|
|
6
|
-
require "ace/runtime/molecules/systemd_scope_manager"
|
|
7
|
-
require "ace/runtime/molecules/protected_artifact_set"
|
|
8
|
-
|
|
9
|
-
module Ace
|
|
10
|
-
module Lab
|
|
11
|
-
module Molecules
|
|
12
|
-
# Only trusted accepted-release composition constructs this observer.
|
|
13
|
-
# Root policy is fixed here; ordinary ProtectedLinux stays all-zero.
|
|
14
|
-
class ProtectedCleanupOwnerIdentity
|
|
15
|
-
Runtime = Ace::Runtime::Molecules
|
|
16
|
-
Unavailable = Ace::Runtime::RuntimeUnavailableError
|
|
17
|
-
DISABLE = "--disable=gems,rubyopt,did_you_mean,error_highlight,syntax_suggest"
|
|
18
|
-
UNIT_FIELDS = {"Id" => "s", "LoadState" => "s", "ActiveState" => "s", "SubState" => "s",
|
|
19
|
-
"InvocationID" => "ay", "FragmentPath" => "s", "DropInPaths" => "as"}.freeze
|
|
20
|
-
EMPTY_COMMANDS = %w[ExecConditionEx ExecStartPreEx ExecStartPostEx ExecReloadEx ExecStopEx ExecStopPostEx].freeze
|
|
21
|
-
ENVIRONMENT = %w[PATH=/usr/bin:/bin LANG=C LC_ALL=C HOME=/root].freeze
|
|
22
|
-
MANAGER_ENVIRONMENT = %w[PATH LANG LANGUAGE LC_ALL LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY
|
|
23
|
-
LC_MESSAGES LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION].freeze
|
|
24
|
-
ENVIRONMENT_FIELDS = {"Environment" => "as", "EnvironmentFiles" => "a(sb)", "PassEnvironment" => "as",
|
|
25
|
-
"UnsetEnvironment" => "as", "PAMName" => "s"}.freeze
|
|
26
|
-
SERVICE_FIELDS = {**ENVIRONMENT_FIELDS, **EMPTY_COMMANDS.to_h { |key| [key, "a(sasasttttuii)"] }, "Slice" => "s", "Type" => "s", "MainPID" => "u", "ControlPID" => "u", "ExecStartEx" => "a(sasasttttuii)"}.freeze
|
|
27
|
-
|
|
28
|
-
class Kernel
|
|
29
|
-
def initialize(identity: Runtime::ProcessIdentity.new, pidfd_open: nil)
|
|
30
|
-
@identity = identity
|
|
31
|
-
@pidfd_open = pidfd_open || method(:open_pidfd)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def capture(pid)
|
|
35
|
-
raise Unavailable, "root owner requires Linux" unless RUBY_PLATFORM.include?("linux") && File.binread("/proc/sys/kernel/yama/ptrace_scope", 128).strip == "2"
|
|
36
|
-
unless pid.is_a?(Integer) && pid.positive?
|
|
37
|
-
raise Unavailable, "root owner PID is unavailable"
|
|
38
|
-
end
|
|
39
|
-
birth = @identity.send(:native_birth, pid)
|
|
40
|
-
raise Unavailable, "root owner birth is unavailable" unless birth
|
|
41
|
-
bytes = File.binread("/proc/#{pid}/status", 65_537)
|
|
42
|
-
raise Unavailable, "root owner status is oversized" if bytes.bytesize > 65_536
|
|
43
|
-
fields = bytes.lines.to_h { |line| line.strip.split(/:\s*/, 2) }
|
|
44
|
-
ids = %w[Uid Gid].map { |key| fields.fetch(key).split.map { |id| Integer(id, 10) } }
|
|
45
|
-
caps = %w[CapInh CapPrm CapEff CapBnd CapAmb].map { |key| Integer(fields.fetch(key), 16) }
|
|
46
|
-
groups = fields.fetch("Groups").split.map { |id| Integer(id, 10) }.sort
|
|
47
|
-
unless ids.all? { |value| value == [0, 0, 0, 0] } && groups == [0] &&
|
|
48
|
-
caps == [0, 2, 2, 2, 0] && fields.fetch("NoNewPrivs") == "1" &&
|
|
49
|
-
@identity.send(:native_birth, pid) == birth
|
|
50
|
-
raise Unavailable, "root owner kernel policy differs"
|
|
51
|
-
end
|
|
52
|
-
{"pid" => pid, "uid" => 0, "gid" => 0, "groups" => groups,
|
|
53
|
-
"started_at" => birth, "host" => Socket.gethostname,
|
|
54
|
-
"parent_pid" => Integer(fields.fetch("PPid"), 10)}
|
|
55
|
-
rescue IOError, SystemCallError, KeyError, ArgumentError
|
|
56
|
-
raise Unavailable, "root owner kernel policy is unavailable"
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def pin(identity)
|
|
60
|
-
handle = @pidfd_open.call(identity.fetch("pid"))
|
|
61
|
-
unless handle.is_a?(IO) && !handle.closed?
|
|
62
|
-
raise Unavailable, "root owner pidfd is unavailable"
|
|
63
|
-
end
|
|
64
|
-
unless capture(identity.fetch("pid")) == identity
|
|
65
|
-
raise Unavailable, "root owner changed during pin acquisition"
|
|
66
|
-
end
|
|
67
|
-
handle
|
|
68
|
-
rescue Fiddle::DLError, SystemCallError, IOError
|
|
69
|
-
handle.close if handle.is_a?(IO) && !handle.closed?
|
|
70
|
-
raise Unavailable, "root owner pidfd capability is unavailable"
|
|
71
|
-
rescue Exception
|
|
72
|
-
handle.close if handle.is_a?(IO) && !handle.closed?
|
|
73
|
-
raise
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def open_pidfd(pid)
|
|
77
|
-
function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["pidfd_open"],
|
|
78
|
-
[Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
|
|
79
|
-
fd = function.call(pid, 0)
|
|
80
|
-
raise Unavailable, "root owner pidfd is unavailable" if fd.negative?
|
|
81
|
-
IO.for_fd(fd, autoclose: true)
|
|
82
|
-
end
|
|
83
|
-
private :open_pidfd
|
|
84
|
-
|
|
85
|
-
def exited?(handle) = !IO.select([handle], nil, nil, 0).nil?
|
|
86
|
-
|
|
87
|
-
def peer(socket)
|
|
88
|
-
raise Unavailable, "root peer requires Linux" unless RUBY_PLATFORM.include?("linux")
|
|
89
|
-
socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_PEERCRED).data.unpack("i!3")
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def initialize(unit:, slice_unit:, entry:, interpreter:, closure:, load_paths:, manager:, kernel: Kernel.new, artifacts: nil)
|
|
94
|
-
unless unit.is_a?(String) && Runtime::SystemdScopeManager::UNIT.match?(unit) && unit.end_with?(".service") &&
|
|
95
|
-
slice_unit.is_a?(String) && Runtime::SystemdScopeManager::UNIT.match?(slice_unit) && slice_unit.end_with?(".slice") &&
|
|
96
|
-
!%w[system.slice user.slice machine.slice].include?(slice_unit) &&
|
|
97
|
-
closure.is_a?(Array) && closure.size.between?(1, 4094) &&
|
|
98
|
-
load_paths.is_a?(Array) && load_paths.size.between?(1, 64) && load_paths.uniq == load_paths &&
|
|
99
|
-
load_paths.all? { |path| path.is_a?(String) && path.start_with?("/") && !path.include?("\0") && !path.include?(":") && File.expand_path(path) == path }
|
|
100
|
-
raise ArgumentError, "cleanup identity requires a fixed accepted-release selection"
|
|
101
|
-
end
|
|
102
|
-
@unit = unit.dup.freeze
|
|
103
|
-
@slice_unit = slice_unit.dup.freeze
|
|
104
|
-
@references = [entry, interpreter, *closure].map { |ref| reference(ref) }.freeze
|
|
105
|
-
unless @references.map { |ref| ref.fetch("path") }.uniq.size == @references.size
|
|
106
|
-
raise ArgumentError, "cleanup closure has duplicate references"
|
|
107
|
-
end
|
|
108
|
-
unless load_paths.all? { |path| @references.any? { |ref| ref.fetch("path").start_with?(path + "/") } }
|
|
109
|
-
raise ArgumentError, "cleanup load path is outside the held closure"
|
|
110
|
-
end
|
|
111
|
-
@entry, @interpreter = @references.first(2)
|
|
112
|
-
@argv = [@interpreter.fetch("path"), DISABLE, "-I", load_paths.join(":"), @entry.fetch("path")].freeze
|
|
113
|
-
unless @argv.sum { |argument| argument.bytesize + 1 } <= 16_384
|
|
114
|
-
raise ArgumentError, "cleanup startup arguments exceed bound"
|
|
115
|
-
end
|
|
116
|
-
@manager, @kernel = manager, kernel
|
|
117
|
-
@verification = Mutex.new
|
|
118
|
-
@artifacts = artifacts || Runtime::ProtectedArtifactSet.new(file_limit: 32 * 1_048_576,
|
|
119
|
-
total_limit: 256 * 1_048_576, count_limit: 4096)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def observe!(socket:, deadline: Runtime::ProtectedSocket.deadline(5))
|
|
123
|
-
with_verification do
|
|
124
|
-
observe_held!(deadline: deadline, peer_join: ->(identity) {
|
|
125
|
-
@kernel.peer(socket) == [identity.fetch("pid"), 0, 0]
|
|
126
|
-
})
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# Only the immutable installed listener calls this entrypoint. Its
|
|
131
|
-
# positive receiver peer is not the root process being measured.
|
|
132
|
-
def observe_self!(deadline: Runtime::ProtectedSocket.deadline(5))
|
|
133
|
-
pid = Process.pid
|
|
134
|
-
with_verification do
|
|
135
|
-
observe_held!(deadline: deadline, peer_join: ->(identity) {
|
|
136
|
-
identity.fetch("pid") == pid
|
|
137
|
-
})
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def with_verification
|
|
142
|
-
unless @verification.try_lock
|
|
143
|
-
raise Unavailable, "cleanup identity verification is busy"
|
|
144
|
-
end
|
|
145
|
-
begin
|
|
146
|
-
yield
|
|
147
|
-
ensure
|
|
148
|
-
@verification.unlock
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
private :with_verification
|
|
152
|
-
|
|
153
|
-
def observe_held!(deadline:, peer_join:)
|
|
154
|
-
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
|
-
unless deadline.is_a?(Numeric) && deadline.finite? && deadline > now
|
|
156
|
-
raise Unavailable, "cleanup identity observation deadline expired"
|
|
157
|
-
end
|
|
158
|
-
deadline = [deadline, now + 5].min
|
|
159
|
-
before = snapshot(deadline)
|
|
160
|
-
identity = @kernel.capture(before.fetch("MainPID"))
|
|
161
|
-
handle = @kernel.pin(identity)
|
|
162
|
-
@artifacts.with do |artifacts|
|
|
163
|
-
@references.each { |ref| artifacts.read!(ref) }
|
|
164
|
-
unless @references.any? { |ref| ref.fetch("path") == before.fetch("FragmentPath") }
|
|
165
|
-
raise Unavailable, "installed cleanup unit is outside the held closure"
|
|
166
|
-
end
|
|
167
|
-
join = @manager.unit_for_pidfd(handle: handle, timeout: remaining(deadline))
|
|
168
|
-
unless join == {"unit" => @unit, "invocation_id" => before.fetch("InvocationID")} &&
|
|
169
|
-
peer_join.call(identity) && !@kernel.exited?(handle) &&
|
|
170
|
-
@kernel.capture(identity.fetch("pid")) == identity && snapshot(deadline) == before
|
|
171
|
-
raise Unavailable, "cleanup peer differs from the original installed invocation"
|
|
172
|
-
end
|
|
173
|
-
artifacts.verify_unchanged!
|
|
174
|
-
unless !@kernel.exited?(handle) && @kernel.capture(identity.fetch("pid")) == identity &&
|
|
175
|
-
@manager.unit_for_pidfd(handle: handle, timeout: remaining(deadline)) == join
|
|
176
|
-
raise Unavailable, "cleanup owner changed after held source verification"
|
|
177
|
-
end
|
|
178
|
-
deep_freeze({"unit" => @unit, "invocation_id" => join.fetch("invocation_id"),
|
|
179
|
-
"process_binding" => identity, "entry_sha256" => @entry.fetch("sha256")})
|
|
180
|
-
end
|
|
181
|
-
ensure
|
|
182
|
-
handle&.close
|
|
183
|
-
end
|
|
184
|
-
private :observe_held!
|
|
185
|
-
|
|
186
|
-
private
|
|
187
|
-
|
|
188
|
-
def snapshot(deadline)
|
|
189
|
-
unit = @manager.typed_properties(unit: @unit, interface: "Unit", signatures: UNIT_FIELDS, timeout: remaining(deadline))
|
|
190
|
-
service = @manager.typed_properties(unit: @unit, interface: "Service", signatures: SERVICE_FIELDS, timeout: remaining(deadline))
|
|
191
|
-
environment = @manager.manager_environment(timeout: remaining(deadline))
|
|
192
|
-
unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) && MANAGER_ENVIRONMENT.include?(entry.split("=", 2).first) } &&
|
|
193
|
-
service.fetch("Environment").sort == ENVIRONMENT.sort &&
|
|
194
|
-
%w[EnvironmentFiles PassEnvironment UnsetEnvironment].all? { |key| service.fetch(key) == [] } && service.fetch("PAMName") == ""
|
|
195
|
-
raise Unavailable, "cleanup original startup environment differs"
|
|
196
|
-
end
|
|
197
|
-
start = service.fetch("ExecStartEx")
|
|
198
|
-
unless unit.values_at("Id", "LoadState", "ActiveState", "SubState", "DropInPaths") == [@unit, "loaded", "active", "running", []] &&
|
|
199
|
-
unit.fetch("InvocationID").match?(/\A[0-9a-f]{32}\z/) && unit.fetch("InvocationID") != "0" * 32 &&
|
|
200
|
-
service.fetch("Slice") == @slice_unit &&
|
|
201
|
-
EMPTY_COMMANDS.all? { |key| service[key] == [] } && service.fetch("Type") == "exec" && service.fetch("MainPID").is_a?(Integer) && service.fetch("MainPID").positive? &&
|
|
202
|
-
service.fetch("ControlPID") == 0 && start.is_a?(Array) && start.size == 1 &&
|
|
203
|
-
start[0][0] == @interpreter.fetch("path") && start[0][1] == @argv && start[0][2] == [] &&
|
|
204
|
-
start[0][3].positive? && start[0][7] == service.fetch("MainPID") && start[0][8..9] == [0, 0]
|
|
205
|
-
raise Unavailable, "cleanup unit execution-start configuration differs"
|
|
206
|
-
end
|
|
207
|
-
unit.merge(service).merge("EffectiveManagerEnvironment" => environment)
|
|
208
|
-
rescue KeyError, NoMethodError, TypeError
|
|
209
|
-
raise Unavailable, "cleanup unit execution facts are unavailable"
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def remaining(deadline)
|
|
213
|
-
value = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
214
|
-
raise Unavailable, "cleanup identity observation deadline expired" unless value.positive?
|
|
215
|
-
[value, 5].min
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def reference(ref)
|
|
219
|
-
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] &&
|
|
220
|
-
ref["path"].is_a?(String) && ref["path"].start_with?("/") && !ref["path"].include?("\0") && File.expand_path(ref["path"]) == ref["path"] &&
|
|
221
|
-
ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
222
|
-
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 32 * 1_048_576)
|
|
223
|
-
raise ArgumentError, "cleanup closure reference is malformed"
|
|
224
|
-
end
|
|
225
|
-
deep_freeze(ref)
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def deep_freeze(value)
|
|
229
|
-
case value
|
|
230
|
-
when Hash then value.to_h { |key, item| [key.dup.freeze, deep_freeze(item)] }.freeze
|
|
231
|
-
when Array then value.map { |item| deep_freeze(item) }.freeze
|
|
232
|
-
when String then value.dup.freeze
|
|
233
|
-
else value.freeze
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
end
|
|
238
|
-
end
|
|
239
|
-
end
|