ace-runtime 0.2.0 → 0.4.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
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # One ephemeral single-thread helper owns every child in this scope.
9
+ # Linux reparents detached orphans to this subreaper, not container PID1.
10
+ # Unreaped direct children pin their PIDs until this owner reaps them.
11
+ class OwnedChildScope
12
+ class CleanupUncertain < StandardError; end
13
+
14
+ def initialize
15
+ unless RUBY_PLATFORM.include?("linux") && Thread.list.count(&:alive?) == 1
16
+ raise CleanupUncertain, "child scope requires a sole Linux helper thread"
17
+ end
18
+ @owner = Process.pid
19
+ raise CleanupUncertain, "child scope has preexisting children" unless children.empty?
20
+ # An inherited ignore/custom reaper could discard a child's wait
21
+ # status and unpin its PID. This sole ephemeral helper owns the
22
+ # disposition; establish it while the verified child set is empty.
23
+ Signal.trap("CHLD", "DEFAULT")
24
+ prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
25
+ [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
26
+ raise CleanupUncertain, "cannot establish child subreaper" unless prctl.call(36, 1, 0, 0, 0).zero?
27
+ end
28
+
29
+ # Preserve the leader's wait status for the executor. Kill/reap only
30
+ # kernel-owned direct children; adoption closes setsid/double-fork escape.
31
+ def cleanup!(leader:, timeout: 5)
32
+ unless Process.pid == @owner && Thread.list.count(&:alive?) == 1 &&
33
+ timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? && timeout <= 5
34
+ raise CleanupUncertain, "child scope ownership is unavailable"
35
+ end
36
+ deadline = monotonic + timeout
37
+ owned = children
38
+ raise CleanupUncertain, "original child is no longer owned" unless owned.include?(leader)
39
+ signal("TERM", -leader)
40
+ loop do
41
+ owned = children
42
+ raise CleanupUncertain, "original child was reaped outside its scope" unless owned.include?(leader)
43
+ owned.each do |pid|
44
+ signal("KILL", pid)
45
+ Process.waitpid(pid, Process::WNOHANG) unless pid == leader
46
+ end
47
+ remaining = children
48
+ return if remaining == [leader] && zombie?(leader)
49
+ raise CleanupUncertain, "owned child cleanup deadline elapsed" if monotonic >= deadline
50
+ IO.select(nil, nil, nil, 0.01)
51
+ end
52
+ rescue Errno::ECHILD, Errno::ENOENT, IOError
53
+ raise CleanupUncertain, "owned child cleanup cannot be confirmed"
54
+ end
55
+
56
+ private
57
+
58
+ def children
59
+ File.open("/proc/self/task/#{@owner}/children", "r") do |file|
60
+ bytes = file.read(65_537) || ""
61
+ raise CleanupUncertain, "owned child inventory exceeds bound" if bytes.bytesize > 65_536
62
+ values = bytes.split.map { |value| Integer(value, 10) }
63
+ unless values.uniq == values && values.all?(&:positive?)
64
+ raise CleanupUncertain, "owned child inventory is invalid"
65
+ end
66
+ values.sort
67
+ end
68
+ end
69
+
70
+ def signal(name, pid)
71
+ Process.kill(name, pid)
72
+ rescue Errno::ESRCH
73
+ nil
74
+ end
75
+
76
+ def zombie?(pid) = File.read("/proc/#{pid}/stat").rpartition(")").last.split.first == "Z"
77
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,6 +3,7 @@
3
3
  require "open3"
4
4
  require "socket"
5
5
  require "fiddle"
6
+ require "time"
6
7
 
7
8
  module Ace
8
9
  module Runtime
@@ -117,6 +118,33 @@ module Ace
117
118
  {"liveness" => "unknown", "observed_at" => at, "reason" => "process identity no longer observable"}
118
119
  end
119
120
  end
121
+
122
+ # Positive evidence that this exact historical generation ended. A
123
+ # failed capture alone says nothing: EPERM/inaccessible probes remain
124
+ # unknown. PID reuse is conclusive only when the exact birth differs.
125
+ def generation_status(identity, process: Process)
126
+ at = Time.now.utc.iso8601
127
+ unknown = {"liveness" => "unknown", "observed_at" => at}
128
+ unless identity.is_a?(Hash) && identity.keys.sort == %w[host pid started_at uid] &&
129
+ identity["host"] == @hostname && identity["pid"].is_a?(Integer) && identity["pid"].positive? &&
130
+ identity["uid"].is_a?(Integer) && identity["uid"] >= 0 &&
131
+ identity["started_at"].is_a?(String) && !identity["started_at"].empty?
132
+ return unknown.merge("reason" => "verified process generation unavailable")
133
+ end
134
+ current = capture(identity.fetch("pid"))
135
+ return {"liveness" => "live", "observed_at" => at, "identity" => current} if current == identity
136
+ if current && current.fetch("started_at") != identity.fetch("started_at")
137
+ return {"liveness" => "ended", "observed_at" => at, "reason" => "pid generation replaced", "identity" => current}
138
+ end
139
+ begin
140
+ process.kill(0, identity.fetch("pid"))
141
+ unknown.merge("reason" => "process address exists but generation is unobservable")
142
+ rescue Errno::ESRCH
143
+ {"liveness" => "ended", "observed_at" => at, "reason" => "kernel reports no process at address"}
144
+ rescue SystemCallError
145
+ unknown.merge("reason" => "kernel process presence is unavailable")
146
+ end
147
+ end
120
148
  end
121
149
  end
122
150
  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