ace-runtime 0.2.0 → 0.3.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.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+ require_relative "server_resource_observation"
3
+
4
+ module Ace
5
+ module Runtime
6
+ module Molecules
7
+ # Pure verification of the fixed supported unit's live report. Installed
8
+ # profile verification and original host proof authentication are separate.
9
+ class KernelViewTopology
10
+ VIEWS = ServerResourceObservation::VIEW_PATHS
11
+ MOUNT_FIELDS = ServerResourceObservation::MOUNT_FIELDS.sort.freeze
12
+ READONLY = %w[/proc/sys /sys /sys/fs/cgroup /dev /dev/shm].freeze
13
+ APIS = {"/proc" => "proc", "/dev/pts" => "devpts", "/dev/mqueue" => "mqueue"}.freeze
14
+
15
+ def verify!(topology:, host_ipc:, authority_socket:, writable_resources:, host_devpts:, selected_devpts:)
16
+ object!(topology, %w[authority_socket_identity hook_ipc_namespace_identity ipc_namespace_identity mounts ptmx_identity ptmx_link views])
17
+ ipc = topology.fetch("ipc_namespace_identity")
18
+ namespace!(ipc)
19
+ namespace!(topology.fetch("hook_ipc_namespace_identity"))
20
+ namespace!(host_ipc)
21
+ refuse! unless ipc == topology.fetch("hook_ipc_namespace_identity") && ipc != host_ipc
22
+ socket = topology.fetch("authority_socket_identity")
23
+ refuse! unless socket.is_a?(Array) && socket.size == 3 && socket.all? { |n| n.is_a?(Integer) && n >= 0 } &&
24
+ socket.first(2).all?(&:positive?) && socket == authority_socket
25
+ mounts = topology.fetch("mounts")
26
+ refuse! unless mounts.is_a?(Array) && mounts.size.between?(1, 256)
27
+ mounts.each { |mount| mount!(mount) }
28
+ refuse! unless mounts.map { |row| row.fetch("mount_id") }.uniq.size == mounts.size
29
+ roots = mounts.select { |row| row.fetch("mountpoint") == "/" }
30
+ refuse! unless !roots.empty? && roots.all? { |row| readonly?(row) }
31
+ views = topology.fetch("views")
32
+ refuse! unless views.is_a?(Array) && views.size == VIEWS.size && views.map { |view| view.fetch("path") }.sort == VIEWS.sort
33
+ by_path = views.to_h do |view|
34
+ object!(view, %w[device inode mount_id path type])
35
+ refuse! unless view["type"] == "directory" && %w[device inode mount_id].all? { |key| positive?(view[key]) }
36
+ row = mounts.find { |mount| mount.fetch("mount_id") == view.fetch("mount_id") }
37
+ refuse! unless row && covers?(row.fetch("mountpoint"), view.fetch("path"))
38
+ [view.fetch("path"), row]
39
+ end
40
+ READONLY.each { |path| refuse! unless readonly?(by_path.fetch(path)) }
41
+ APIS.each do |path, filesystem|
42
+ row = by_path.fetch(path)
43
+ refuse! unless row.fetch("mountpoint") == path && row.fetch("root") == "/" && row.fetch("filesystem_type") == filesystem
44
+ end
45
+ object!(host_devpts, %w[device inode major_minor])
46
+ object!(selected_devpts, %w[device inode major_minor path ptmx_inode])
47
+ [host_devpts, selected_devpts].each do |instance|
48
+ refuse! unless %w[device inode].all? { |key| positive?(instance[key]) } &&
49
+ instance["major_minor"].is_a?(String) && instance["major_minor"].match?(/\A(?:0|[1-9][0-9]*):(?:0|[1-9][0-9]*)\z/)
50
+ end
51
+ refuse! unless positive?(selected_devpts["ptmx_inode"]) && path?(selected_devpts["path"])
52
+ pts = views.find { |view| view["path"] == "/dev/pts" }
53
+ pts_mount = by_path.fetch("/dev/pts")
54
+ refuse! unless pts.values_at("device", "inode") == selected_devpts.values_at("device", "inode") &&
55
+ pts_mount["major_minor"] == selected_devpts["major_minor"] &&
56
+ selected_devpts["device"] != host_devpts["device"] && selected_devpts["major_minor"] != host_devpts["major_minor"] &&
57
+ topology["ptmx_link"] == "pts/ptmx"
58
+ ptmx = topology.fetch("ptmx_identity")
59
+ object!(ptmx, %w[device inode mount_id rdev_major rdev_minor type])
60
+ refuse! unless ptmx["type"] == "character" && %w[device inode mount_id].all? { |key| positive?(ptmx[key]) } &&
61
+ ptmx["rdev_major"].is_a?(Integer) && ptmx["rdev_minor"].is_a?(Integer) &&
62
+ ptmx.values_at("rdev_major", "rdev_minor") == [5, 2] &&
63
+ ptmx.values_at("device", "inode") == selected_devpts.values_at("device", "ptmx_inode")
64
+ node_mount = mounts.find { |row| row["mount_id"] == ptmx["mount_id"] }
65
+ refuse! unless node_mount && readonly?(node_mount) &&
66
+ node_mount.values_at("mountpoint", "root", "filesystem_type", "major_minor") ==
67
+ ["/dev/pts/ptmx", "/ptmx", "devpts", selected_devpts["major_minor"]]
68
+ hidden_host = mounts.select { |row| row["major_minor"] == host_devpts["major_minor"] }
69
+ hidden_host.each do |row|
70
+ refuse! unless row.values_at("mountpoint", "root", "filesystem_type") == ["/dev/pts", "/", "devpts"]
71
+ end
72
+ refuse! unless hidden_host.size <= 1
73
+ pts_layers = mounts.select { |row| row["mountpoint"] == "/dev/pts" }
74
+ refuse! unless (pts_layers - [pts_mount] - hidden_host).empty?
75
+ unless hidden_host.empty?
76
+ chain = []
77
+ parent = pts_mount
78
+ while parent["mountpoint"] == "/dev/pts"
79
+ refuse! if chain.include?(parent["mount_id"])
80
+ chain << parent.fetch("mount_id")
81
+ parent = mounts.find { |row| row["mount_id"] == parent["parent_id"] }
82
+ refuse! unless parent
83
+ end
84
+ refuse! unless hidden_host.all? { |row| chain.include?(row["mount_id"]) }
85
+ end
86
+ refuse! unless by_path.fetch("/dev").fetch("filesystem_type") == "tmpfs" && by_path.fetch("/dev").fetch("mountpoint") == "/dev"
87
+ mounts.each do |row|
88
+ next if hidden_host.include?(row)
89
+ next if readonly?(row)
90
+ api = APIS[row.fetch("mountpoint")]
91
+ next if api && row == by_path.fetch(row.fetch("mountpoint")) && row.fetch("filesystem_type") == api
92
+ refuse! if %w[/dev /proc /sys].any? { |root| covers?(root, row.fetch("mountpoint")) }
93
+ refuse! unless writable_resources.any? do |resource|
94
+ row.values_at("mountpoint", "major_minor", "root") == resource.values_at("view_path", "major_minor", "filesystem_path") &&
95
+ row.fetch("filesystem_type") == resource.fetch("filesystem_type")
96
+ end
97
+ end
98
+ %w[/tmp /var/tmp].each do |path|
99
+ row = by_path.fetch(path)
100
+ refuse! unless readonly?(row) || writable_resources.any? { |resource| resource["view_path"] == path && row["mountpoint"] == path }
101
+ end
102
+ true
103
+ rescue KeyError, TypeError, NoMethodError, ArgumentError
104
+ refuse!
105
+ end
106
+
107
+ private
108
+
109
+ def mount!(row)
110
+ object!(row, MOUNT_FIELDS)
111
+ refuse! unless positive?(row["mount_id"]) && row["parent_id"].is_a?(Integer) && row["parent_id"] >= 0 &&
112
+ row["major_minor"].is_a?(String) && row["major_minor"].match?(/\A[0-9]+:[0-9]+\z/) &&
113
+ path?(row["root"]) && path?(row["mountpoint"]) && row["filesystem_type"].is_a?(String) &&
114
+ row["filesystem_type"].match?(/\A[a-zA-Z0-9_.-]+\z/) && row["options"].is_a?(Array) &&
115
+ row["options"].all? { |option| option.is_a?(String) && option.bytesize.between?(1, 128) } &&
116
+ row["options"].uniq == row["options"] && (row["options"] & %w[ro rw]).size == 1
117
+ end
118
+ def object!(value, fields)
119
+ refuse! unless value.is_a?(Hash) && value.keys.sort == fields.sort
120
+ end
121
+ def namespace!(value)
122
+ object!(value, %w[device inode])
123
+ refuse! unless value.values.all? { |number| positive?(number) }
124
+ end
125
+ def positive?(number) = number.is_a?(Integer) && number.positive?
126
+ def path?(path) = path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path
127
+ def covers?(root, path) = root == "/" || root == path || path.start_with?(root + "/")
128
+ def readonly?(row) = row.fetch("options").include?("ro")
129
+ def refuse! = raise(RuntimeUnavailableError, "live kernel/resource view does not match the supported profile")
130
+ end
131
+ end
132
+ end
133
+ end
@@ -48,8 +48,17 @@ module Ace
48
48
  def trusted_owner?(stat) = stat.uid.zero?
49
49
  end
50
50
 
51
- def initialize(protection: Protection.new)
51
+ def initialize(protection: Protection.new, file_limit: LIMIT, total_limit: TOTAL_LIMIT, count_limit: COUNT_LIMIT)
52
+ unless file_limit.is_a?(Integer) && total_limit.is_a?(Integer) &&
53
+ file_limit.positive? && total_limit.positive? && file_limit <= total_limit
54
+ raise ArgumentError, "invalid protected artifact byte budgets"
55
+ end
56
+ unless count_limit.is_a?(Integer) && count_limit.positive?
57
+ raise ArgumentError, "invalid protected artifact count budget"
58
+ end
52
59
  @protection = protection
60
+ @file_limit, @total_limit = file_limit, total_limit
61
+ @count_limit = count_limit
53
62
  end
54
63
 
55
64
  def with
@@ -67,9 +76,9 @@ module Ace
67
76
  raise RuntimeUnavailableError, "network artifact references conflict" unless previous == reference
68
77
  return @contents.fetch(path)
69
78
  end
70
- raise RuntimeUnavailableError, "network artifact graph is oversized" if @references.size >= COUNT_LIMIT
79
+ raise RuntimeUnavailableError, "network artifact graph is oversized" if @references.size >= @count_limit
71
80
  size = reference.fetch("bytes")
72
- unless size.is_a?(Integer) && size.between?(1, LIMIT) && @total + size <= TOTAL_LIMIT
81
+ unless size.is_a?(Integer) && size.between?(1, @file_limit) && @total + size <= @total_limit
73
82
  raise RuntimeUnavailableError, "network artifact bytes exceed bounds"
74
83
  end
75
84
  @protection.root_path!(path)
@@ -89,11 +98,66 @@ module Ace
89
98
  bytes
90
99
  end
91
100
 
101
+ # Trusted process handoff inside the same bounded verification. The
102
+ # duplicate refers to the authenticated retained inode, never a reopened
103
+ # pathname, and is closed before this callback can outlive the set.
104
+ def with_readonly_handle!(reference)
105
+ unless @handles && @references
106
+ raise RuntimeUnavailableError, "protected handle requires an active verification"
107
+ end
108
+ read!(reference)
109
+ verify_unchanged!
110
+ duplicate = @handles.fetch(reference.fetch("path")).fetch(:handle).dup
111
+ duplicate.close_on_exec = true
112
+ duplicate.rewind
113
+ begin
114
+ yield duplicate
115
+ ensure
116
+ verify_unchanged!
117
+ end
118
+ ensure
119
+ duplicate&.close unless duplicate&.closed?
120
+ end
121
+
122
+ # Fixed installer entrypoints have no caller-supplied digest. Derive the
123
+ # reference from the same protected descriptor whose bytes are retained.
124
+ def read_path!(path, limit: @file_limit)
125
+ raise ArgumentError, "invalid artifact byte limit" unless limit.is_a?(Integer) && limit.between?(1, @file_limit)
126
+ raise ArgumentError, "invalid fixed artifact path" unless path.is_a?(String) &&
127
+ path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path
128
+ if @references.key?(path)
129
+ reference = @references.fetch(path)
130
+ raise RuntimeUnavailableError, "fixed artifact exceeds bounds" if reference.fetch("bytes") > limit
131
+ return [@contents.fetch(path), reference].freeze
132
+ end
133
+ @protection.root_path!(path)
134
+ ancestors(path).reverse_each { |ancestor| pin!(ancestor, directory: true) }
135
+ handle = pin!(path, directory: false)
136
+ size = handle.stat.size
137
+ unless size.between?(1, limit) && @references.size < @count_limit && @total + size <= @total_limit
138
+ raise RuntimeUnavailableError, "fixed artifact exceeds bounds"
139
+ end
140
+ bytes = handle.read(size + 1)
141
+ unless bytes.is_a?(String) && bytes.bytesize == size
142
+ raise RuntimeUnavailableError, "fixed artifact length changed"
143
+ end
144
+ reference = {"path" => path.dup.freeze, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
145
+ "bytes" => size}.freeze
146
+ if @references.key?(path)
147
+ raise RuntimeUnavailableError, "fixed artifact references conflict" unless @references.fetch(path) == reference
148
+ else
149
+ @references[path] = reference
150
+ @contents[path] = bytes.freeze
151
+ @total += size
152
+ end
153
+ [@contents.fetch(path), reference].freeze
154
+ end
155
+
92
156
  def verify_unchanged!
93
157
  @handles.each do |path, entry|
94
158
  handle = entry.fetch(:handle)
95
- unless snapshot(handle.stat) == entry.fetch(:snapshot) &&
96
- snapshot(File.lstat(path)) == entry.fetch(:snapshot)
159
+ unless snapshot(handle.stat, directory: entry.fetch(:directory)) == entry.fetch(:snapshot) &&
160
+ snapshot(File.lstat(path), directory: entry.fetch(:directory)) == entry.fetch(:snapshot)
97
161
  raise RuntimeUnavailableError, "network artifact changed during verification"
98
162
  end
99
163
  @protection.verify!(path, handle, directory: entry.fetch(:directory))
@@ -126,10 +190,10 @@ module Ace
126
190
  begin
127
191
  handle.close_on_exec = true
128
192
  @protection.verify!(path, handle, directory: directory)
129
- unless snapshot(stat) == snapshot(handle.stat) && snapshot(File.lstat(path)) == snapshot(handle.stat)
193
+ unless snapshot(stat, directory: directory) == snapshot(handle.stat, directory: directory) && snapshot(File.lstat(path), directory: directory) == snapshot(handle.stat, directory: directory)
130
194
  raise RuntimeUnavailableError, "network artifact changed while opening"
131
195
  end
132
- @handles[path] = {handle: handle, directory: directory, snapshot: snapshot(handle.stat)}
196
+ @handles[path] = {handle: handle, directory: directory, snapshot: snapshot(handle.stat, directory: directory)}
133
197
  handle
134
198
  rescue Exception
135
199
  handle.close
@@ -137,8 +201,9 @@ module Ace
137
201
  end
138
202
  end
139
203
 
140
- def snapshot(stat)
141
- [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime]
204
+ def snapshot(stat, directory:)
205
+ identity = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
206
+ directory ? identity : identity + [stat.size, stat.mtime, stat.ctime]
142
207
  end
143
208
  end
144
209
  end
@@ -7,6 +7,22 @@ module Ace
7
7
  module Molecules
8
8
  module ProtectedSocket
9
9
  LIMIT = 65_536
10
+ # Fixed protocols that forbid descriptors use this for every control,
11
+ # binary-body and EOF read; rights never bypass a later framing check.
12
+ class Ingress
13
+ def initialize(socket) = @socket = socket
14
+ def to_io = @socket
15
+
16
+ def read_nonblock(length, exception: false)
17
+ result = @socket.recvmsg_nonblock(length, 0, 4096, scm_rights: true, exception: exception)
18
+ return result if result == :wait_readable
19
+ return nil if result.nil?
20
+ bytes, _, flags, *controls = result
21
+ controls.each { |control| control.unix_rights&.each(&:close) if control.cmsg_is?(Socket::SOL_SOCKET, Socket::SCM_RIGHTS) }
22
+ raise SecurityError, "protected ancillary input is forbidden" unless controls.empty? && (flags & Socket::MSG_CTRUNC).zero?
23
+ bytes.empty? ? nil : bytes
24
+ end
25
+ end
10
26
  module_function
11
27
 
12
28
  def root_path!(path, directory: false, owner: 0)
@@ -48,9 +64,12 @@ module Ace
48
64
  socket&.close unless socket&.closed?
49
65
  end
50
66
 
51
- def socket_identity(path)
67
+ def socket_identity(path, mode: nil)
52
68
  stat = File.lstat(path)
53
69
  raise RuntimeUnavailableError, "protected endpoint is not a socket" unless stat.socket? && !stat.symlink?
70
+ if !mode.nil? && (!mode.is_a?(Integer) || !mode.between?(0, 0o7777) || (stat.mode & 0o7777) != mode)
71
+ raise RuntimeUnavailableError, "protected endpoint mode differs"
72
+ end
54
73
  [stat.dev, stat.ino, stat.uid]
55
74
  rescue SystemCallError
56
75
  raise RuntimeUnavailableError, "protected endpoint is unavailable"
@@ -69,7 +88,10 @@ module Ace
69
88
  raise RuntimeUnavailableError, "protected socket frame is oversized" if buffer.bytesize > limit
70
89
  break if chunk == "\n"
71
90
  end
72
- value = JSON.parse(buffer)
91
+ buffer.force_encoding(Encoding::UTF_8)
92
+ raise RuntimeUnavailableError, "protected socket frame is malformed" unless buffer.valid_encoding?
93
+ value = JSON.parse(buffer, create_additions: false, max_nesting: 32,
94
+ allow_duplicate_key: false, allow_comments: false)
73
95
  with_size ? {data: value, bytesize: buffer.bytesize} : value
74
96
  rescue JSON::ParserError
75
97
  raise RuntimeUnavailableError, "protected socket frame is malformed"
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "protected_artifact_set"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # Ref-only installed discovery. Domain publication owns the projection;
9
+ # the selected child remains the original scope/admission authority.
10
+ class ProtectedTaskContextEntry
11
+ PATH = "/etc/ace/task-context-entry.json"
12
+ PRESENCE_PATHS = %w[/etc/ace/assignment-authorities.json /etc/ace/assignment-deployment-history.json /usr/local/lib/lab/herdr.installation.json].freeze
13
+ SCHEMA = "ace.protected-task-context-entry-selection/v1"
14
+ MANIFEST_SCHEMA = "lab.protected-command-entry/v1"
15
+ ROLE = "ace-assign-task-context"
16
+ METADATA_LIMIT = 65_536
17
+ FILE_LIMIT = 33_554_432
18
+ TOTAL_LIMIT = 268_435_456
19
+ COUNT_LIMIT = 4_096
20
+ Entry = Struct.new(:pin, :manifest, :body, :interpreter, keyword_init: true)
21
+
22
+ def initialize(artifacts_factory: -> { ProtectedArtifactSet.new(file_limit: FILE_LIMIT, total_limit: TOTAL_LIMIT, count_limit: COUNT_LIMIT) },
23
+ stat: File.method(:lstat))
24
+ @factory, @stat = artifacts_factory, stat
25
+ end
26
+
27
+ # Pure shape validation, never publication/original admission authority.
28
+ def validate_pin!(pin)
29
+ pin!(pin)
30
+ end
31
+
32
+ def with
33
+ unless present?(PATH)
34
+ refuse!("installed discovery is absent") if PRESENCE_PATHS.any? { |path| present?(path) }
35
+ return yield nil
36
+ end
37
+ @factory.call.with do |held|
38
+ @held = held
39
+ bytes, = held.read_path!(PATH, limit: METADATA_LIMIT)
40
+ projection = json!(bytes)
41
+ object!(projection, %w[schema task_context_entry])
42
+ refuse!("discovery schema") unless projection.fetch("schema") == SCHEMA
43
+ pin = pin!(projection.fetch("task_context_entry"))
44
+ held.verify_unchanged!
45
+ result = yield pin
46
+ held.verify_unchanged!
47
+ result
48
+ ensure
49
+ @held = nil
50
+ end
51
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
52
+ refuse!("installed discovery is unavailable")
53
+ end
54
+
55
+ # Live descriptor lifetime is this callback only. Exact protected bytes
56
+ # go to stdin; callers never execute/reopen a manifest-selected pathname.
57
+ def with_entry(pin)
58
+ refuse!("entry requires held discovery") unless @held
59
+ pin = pin!(pin)
60
+ manifest = json!(@held.read!(pin.fetch("manifest")))
61
+ object!(manifest, %w[schema role wrapper interpreter bootstrap])
62
+ unless manifest.fetch("schema") == MANIFEST_SCHEMA && manifest.fetch("role") == ROLE && manifest.fetch("wrapper") == pin.fetch("wrapper")
63
+ refuse!("selected fixed role differs")
64
+ end
65
+ reference!(manifest.fetch("interpreter"))
66
+ bootstrap = manifest.fetch("bootstrap")
67
+ object!(bootstrap, %w[original_source owner preparation startup_entries])
68
+ %w[original_source owner preparation].each do |key|
69
+ reference!(bootstrap.fetch(key))
70
+ @held.read!(bootstrap.fetch(key))
71
+ end
72
+ entries = bootstrap.fetch("startup_entries")
73
+ refuse!("bootstrap entry references") unless entries.is_a?(Hash)
74
+ reference!(entries.fetch("ace-assign"))
75
+ read_references!(entries)
76
+ body = @held.read!(pin.fetch("wrapper"))
77
+ text = body.dup.force_encoding(Encoding::UTF_8)
78
+ refuse!("wrapper source encoding") unless text.valid_encoding? && !text.include?("\0")
79
+ @held.with_readonly_handle!(manifest.fetch("interpreter")) do |interpreter|
80
+ stat = interpreter.stat
81
+ refuse!("selected interpreter is not executable") unless stat.file? && stat.executable? && (stat.mode & 0o6000).zero?
82
+ @held.verify_unchanged!
83
+ result = yield Entry.new(pin: pin, manifest: immutable(manifest), body: body, interpreter: interpreter).freeze
84
+ @held.verify_unchanged!
85
+ result
86
+ end
87
+ rescue KeyError, TypeError, NoMethodError, ArgumentError, IOError, SystemCallError, EncodingError
88
+ refuse!("selected protected entry is unavailable")
89
+ end
90
+
91
+ private
92
+
93
+ def present?(path)
94
+ @stat.call(path)
95
+ true
96
+ rescue Errno::ENOENT
97
+ false
98
+ end
99
+
100
+ def pin!(pin)
101
+ object!(pin, %w[manifest wrapper])
102
+ reference!(pin.fetch("manifest"), limit: METADATA_LIMIT)
103
+ reference!(pin.fetch("wrapper"))
104
+ immutable(pin)
105
+ end
106
+
107
+ def reference!(reference, limit: FILE_LIMIT)
108
+ object!(reference, %w[bytes path sha256])
109
+ path = reference.fetch("path")
110
+ unless path.is_a?(String) && path.encoding == Encoding::UTF_8 && path.valid_encoding? &&
111
+ path.bytesize.between?(2, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path &&
112
+ reference.fetch("bytes").is_a?(Integer) && reference.fetch("bytes").between?(1, limit) &&
113
+ reference.fetch("sha256").is_a?(String) && reference.fetch("sha256").match?(/\A[0-9a-f]{64}\z/)
114
+ refuse!("protected entry reference")
115
+ end
116
+ end
117
+
118
+ def read_references!(root)
119
+ pending = [root]
120
+ until pending.empty?
121
+ value = pending.pop
122
+ if value.is_a?(Hash) && value.keys.sort == %w[bytes path sha256]
123
+ reference!(value)
124
+ @held.read!(value)
125
+ elsif value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) }
126
+ pending.concat(value.values)
127
+ elsif value.is_a?(Array)
128
+ pending.concat(value)
129
+ else
130
+ refuse!("bootstrap reference graph")
131
+ end
132
+ end
133
+ end
134
+
135
+ def json!(bytes)
136
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
137
+ refuse!("entry metadata encoding or bound") unless text.valid_encoding? && text.bytesize.between?(1, METADATA_LIMIT)
138
+ JSON.parse(text, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
139
+ rescue JSON::ParserError
140
+ refuse!("entry metadata JSON")
141
+ end
142
+
143
+ def object!(value, fields)
144
+ refuse!("entry fields") unless value.is_a?(Hash) && value.keys.sort == fields.sort
145
+ end
146
+
147
+ def immutable(value)
148
+ case value
149
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
150
+ when Array then value.map { |item| immutable(item) }.freeze
151
+ when String then value.dup.freeze
152
+ else value.freeze
153
+ end
154
+ end
155
+
156
+ def refuse!(detail)
157
+ raise RuntimeUnavailableError, "protected task-context entry unavailable: #{detail}"
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Molecules
6
+ # Structural admission of source-owned pins. Artifact authentication belongs
7
+ # to the selected entry owner; these references are never an execution grant.
8
+ module ProtectedWorkerEntry
9
+ LIMITS = {"interpreter" => 33_554_432, "wrapper" => 1_048_576}.freeze
10
+
11
+ def self.validate!(value)
12
+ unless value.is_a?(Hash) && value.keys.sort == LIMITS.keys.sort
13
+ raise ArgumentError, "worker entry fields differ"
14
+ end
15
+ LIMITS.each do |key, limit|
16
+ reference = value[key]
17
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
18
+ reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, limit) &&
19
+ reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
20
+ reference["path"].is_a?(String) && reference["path"].encoding == Encoding::UTF_8 &&
21
+ reference["path"].valid_encoding? && reference["path"].bytesize.between?(2, 4096) &&
22
+ !reference["path"].include?("\0") && reference["path"].start_with?("/") &&
23
+ File.expand_path(reference["path"]) == reference["path"]
24
+ raise ArgumentError, "worker entry reference differs"
25
+ end
26
+ end
27
+ value
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require_relative "protected_artifact_set"
6
+
7
+ module Ace
8
+ module Runtime
9
+ module Molecules
10
+ # Immutable local installer input. This contains selections only; live
11
+ # process and resource observations must come from their owning readers.
12
+ class ReadinessConfiguration
13
+ LIMIT = 65_536
14
+ SCHEMA = "ace.assign.readiness/v1"
15
+ FIELDS = %w[authority boundary_manifest mapping_id native project_id runtime schema slot_id worker].freeze
16
+ attr_reader :data
17
+
18
+ def self.path(slot)
19
+ unless slot.is_a?(String) && slot.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
20
+ raise RuntimeUnavailableError, "invalid readiness slot"
21
+ end
22
+ "/etc/ace/execution-slots/#{slot}/readiness.json"
23
+ end
24
+
25
+ def self.decode(bytes, slot:)
26
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
27
+ unless text.bytesize.between?(1, LIMIT) && text.valid_encoding?
28
+ raise RuntimeUnavailableError, "readiness configuration is not bounded UTF-8"
29
+ end
30
+ new(JSON.parse(text, create_additions: false, max_nesting: 8,
31
+ allow_duplicate_key: false, allow_comments: false), slot: slot)
32
+ rescue JSON::ParserError, EncodingError
33
+ raise RuntimeUnavailableError, "readiness configuration is not strict JSON"
34
+ end
35
+
36
+ def self.load(slot:)
37
+ ProtectedArtifactSet.new.with do |artifacts|
38
+ bytes, = artifacts.read_path!(path(slot), limit: LIMIT)
39
+ value = decode(bytes, slot: slot)
40
+ artifacts.verify_unchanged!
41
+ value
42
+ end
43
+ end
44
+
45
+ def initialize(data, slot:)
46
+ object!(data, FIELDS)
47
+ unless data["schema"] == SCHEMA && data["slot_id"] == slot
48
+ refuse!("readiness configuration selection differs")
49
+ end
50
+ %w[mapping_id project_id].each do |key|
51
+ refuse!("invalid readiness identifier") unless data[key].is_a?(String) &&
52
+ data[key].match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
53
+ end
54
+ object!(data.fetch("authority"), %w[gid groups socket_path uid])
55
+ object!(data.fetch("worker"), %w[gid groups uid])
56
+ %w[authority worker].each do |kind|
57
+ principal = data.fetch(kind)
58
+ unless %w[uid gid].all? { |key| principal[key].is_a?(Integer) && principal[key].positive? } &&
59
+ principal["groups"].is_a?(Array) && principal["groups"].all? { |gid| gid.is_a?(Integer) && gid.positive? } &&
60
+ principal["groups"].sort.uniq == principal["groups"]
61
+ refuse!("readiness principal differs")
62
+ end
63
+ end
64
+ native = data.fetch("native")
65
+ object!(native, %w[executable executable_sha256 protocol socket_path version workspace_id])
66
+ unless native["protocol"] == 22 && native["version"] == "0.9.3" &&
67
+ native["workspace_id"].is_a?(String) && native["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/)
68
+ refuse!("readiness native protocol differs")
69
+ end
70
+ digest!(native["executable_sha256"])
71
+ boundary = data.fetch("boundary_manifest")
72
+ object!(boundary, %w[path sha256])
73
+ unless boundary["path"] == "/etc/ace/execution-slots/#{slot}/boundary-manifest.json"
74
+ refuse!("readiness boundary path differs")
75
+ end
76
+ digest!(boundary["sha256"])
77
+ [data.dig("authority", "socket_path"), native["socket_path"], native["executable"]].each { |path| path!(path) }
78
+ runtime = data.fetch("runtime")
79
+ object!(runtime, %w[dependencies interpreter_path load_paths])
80
+ path!(runtime.fetch("interpreter_path"))
81
+ paths = runtime.fetch("load_paths")
82
+ unless paths.is_a?(Array) && paths.size.between?(1, 16) && paths.uniq == paths
83
+ refuse!("readiness load paths differ")
84
+ end
85
+ paths.each { |path| path!(path) }
86
+ dependencies = runtime.fetch("dependencies")
87
+ unless dependencies.is_a?(Array) && dependencies.size.between?(1, 256) &&
88
+ dependencies.map { |ref| ref.fetch("path") }.uniq.size == dependencies.size
89
+ refuse!("readiness dependency closure differs")
90
+ end
91
+ dependencies.each do |ref|
92
+ object!(ref, %w[bytes path sha256])
93
+ path!(ref.fetch("path"))
94
+ digest!(ref.fetch("sha256"))
95
+ unless ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, ProtectedArtifactSet::LIMIT)
96
+ refuse!("readiness dependency size differs")
97
+ end
98
+ end
99
+ @data = immutable(data)
100
+ freeze
101
+ rescue KeyError, TypeError, ArgumentError
102
+ refuse!("readiness configuration fields differ")
103
+ end
104
+
105
+ def verify_selection!(mapping_id:, mapping:, authority:, installation:)
106
+ expected = {"schema" => SCHEMA, "slot_id" => mapping.fetch("execution_scope").fetch("slot_id"),
107
+ "mapping_id" => mapping_id, "project_id" => mapping.fetch("project_id"),
108
+ "authority" => authority.slice("socket_path", "uid", "gid", "groups"),
109
+ "worker" => {"uid" => mapping.fetch("worker_uid"), "gid" => mapping.fetch("worker_gid"),
110
+ "groups" => mapping.fetch("worker_groups")}, "native" => mapping.fetch("native"),
111
+ "boundary_manifest" => {"path" => "/etc/ace/execution-slots/#{data.fetch('slot_id')}/boundary-manifest.json",
112
+ "sha256" => mapping.fetch("execution_scope").fetch("boundary_manifest_sha256")}, "runtime" => data.fetch("runtime")}
113
+ refuse!("readiness configuration does not join protected deployment") unless data == expected
114
+ artifacts = installation.fetch("artifacts")
115
+ closure = data.fetch("runtime").fetch("dependencies")
116
+ unless closure.all? { |ref| artifacts.any? { |artifact|
117
+ %w[runtime_dependency readiness_executable].include?(artifact.fetch("role")) &&
118
+ artifact.fetch("view_path") == ref.fetch("path") && artifact.fetch("sha256") == ref.fetch("sha256") } } &&
119
+ artifacts.any? { |artifact| artifact.fetch("role") == "runtime_dependency" &&
120
+ artifact.fetch("view_path") == data.dig("runtime", "interpreter_path") }
121
+ refuse!("readiness dependency closure does not join installation")
122
+ end
123
+ true
124
+ end
125
+
126
+ private
127
+
128
+ def object!(value, keys)
129
+ refuse!("readiness object fields differ") unless value.is_a?(Hash) && value.keys.sort == keys.sort
130
+ end
131
+
132
+ def path!(value)
133
+ refuse!("readiness path differs") unless value.is_a?(String) && value.bytesize.between?(2, 4096) &&
134
+ value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
135
+ end
136
+
137
+ def digest!(value)
138
+ refuse!("readiness digest differs") unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
139
+ end
140
+
141
+ def immutable(value)
142
+ case value
143
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
144
+ when Array then value.map { |item| immutable(item) }.freeze
145
+ when String then value.dup.freeze
146
+ else value
147
+ end
148
+ end
149
+
150
+ def refuse!(message) = raise(RuntimeUnavailableError, message)
151
+ end
152
+ end
153
+ end
154
+ end